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/notify.py ADDED
@@ -0,0 +1,329 @@
1
+ """Notifications: tell people when the library agent or upkeep has something for them (Slack, Microsoft Teams, any
2
+ JSON webhook). Off unless [notify] has targets.
3
+
4
+ [notify]
5
+ retries = 3 # more tries after a failed post (backoff_s, then 2x, 4x ...; 429 and 5xx only)
6
+ timeout_s = 10 # per request
7
+ backoff_s = 2.0
8
+
9
+ [[notify.targets]]
10
+ name = "library-channel"
11
+ type = "slack" # slack (incoming webhook) | teams (Teams workflow webhook) | json (generic POST)
12
+ url_env = "SLACK_WEBHOOK_URL" # NAME of the environment variable holding the webhook URL
13
+ events = ["batch_ready", "part_held"] # default (or "*"): every event
14
+
15
+ [[notify.targets]]
16
+ name = "erp"
17
+ type = "json"
18
+ url_env = "ERP_HOOK_URL"
19
+ token_env = "ERP_HOOK_TOKEN" # optional, json only: sent as Authorization: Bearer <value>
20
+ events = ["lifecycle"]
21
+
22
+ Webhook URLs carry their own secret (Slack, Teams), so the file names environment variables only; a URL or token
23
+ written into the file is refused by `ecadlib rules check`. Error messages name the target and host, never the URL.
24
+
25
+ Events:
26
+ batch_ready library agent: a review batch was published (pull / merge request opened or updated, or its folder)
27
+ part_held library agent: parts it could not build or that failed their proof (queue status failed)
28
+ upkeep_changes upkeep (`ecadlib upkeep`, or the agent's MAINTAIN step): new changes since the last run
29
+ lifecycle a part went EOL / obsolete, NRND or last time buy
30
+
31
+ Sending never raises and never stops the agent: a target whose variable is unset is skipped, a failed post is retried
32
+ with backoff inside a fixed time budget (max_seconds; once it is used up, the remaining posts are skipped), and every
33
+ outcome is returned (the agent puts them in its report as rep["notify"]). Tests pass sender=fake and sleep=fake.
34
+
35
+ ecadlib notify test [--event batch_ready] [--target NAME] send a test message to the configured targets
36
+ ecadlib notify show targets, events and whether their variables are set"""
37
+ import argparse, json, re, sys, time
38
+ from urllib.parse import urlsplit
39
+
40
+ EVENTS = ("batch_ready", "part_held", "upkeep_changes", "lifecycle")
41
+ TYPES = ("slack", "teams", "json")
42
+ LIFECYCLE_KINDS = {"eol", "obsolete", "nrnd", "last time buy", "ltb"}
43
+ TARGET_KEYS = ("name", "type", "url_env", "token_env", "events")
44
+ ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
45
+ MAX_SECONDS = 60.0 # all retries of one send() together wait at most this long
46
+ MAX_LINES = 20
47
+
48
+
49
+ # ------------------------------------------------------------------ rules validation (called from rules.validate)
50
+ def check_targets(where, v, err):
51
+ """[[notify.targets]]: a list of tables with the keys above. err(where, key, msg) raises."""
52
+ if not isinstance(v, list):
53
+ err(where, "notify.targets", "must be a list of { name, type, url_env, events } tables")
54
+ names = set()
55
+ for i, t in enumerate(v):
56
+ k = f"notify.targets[{i}]"
57
+ if not isinstance(t, dict):
58
+ err(where, k, "must be a table such as { type = \"slack\", url_env = \"SLACK_WEBHOOK_URL\" }")
59
+ bad = [x for x in t if x not in TARGET_KEYS]
60
+ if bad:
61
+ hint = " (put the URL in an environment variable and name it in url_env)" if bad[0] in ("url", "webhook", "token") else ""
62
+ err(where, f"{k}.{bad[0]}", f"is not a known key; known: {', '.join(TARGET_KEYS)}{hint}")
63
+ if t.get("type") not in TYPES:
64
+ err(where, f"{k}.type", f"= {t.get('type')!r}: must be one of {', '.join(TYPES)}")
65
+ for e in ("url_env", "token_env"):
66
+ if e in t and (not isinstance(t[e], str) or (t[e] and not ENV_NAME.fullmatch(t[e]))):
67
+ err(where, f"{k}.{e}", "must be the NAME of an environment variable (never the URL or token itself)")
68
+ if not t.get("url_env"):
69
+ err(where, f"{k}.url_env", "is required: the NAME of the environment variable holding the webhook URL")
70
+ if t.get("token_env") and t.get("type") != "json":
71
+ err(where, f"{k}.token_env", "is only used by type = \"json\" (Slack and Teams URLs carry their own secret)")
72
+ ev = t.get("events", ["*"])
73
+ ev = [ev] if isinstance(ev, str) else ev
74
+ if not isinstance(ev, list) or any(x != "*" and x not in EVENTS for x in ev):
75
+ err(where, f"{k}.events", f"= {t.get('events')!r}: events are {', '.join(EVENTS)} (or \"*\")")
76
+ name = t.get("name", f"{t.get('type')}{i + 1}")
77
+ if not isinstance(name, str) or name in names:
78
+ err(where, f"{k}.name", f"= {name!r}: must be text, different for each target")
79
+ names.add(name)
80
+
81
+
82
+ def settings(rules):
83
+ from . import rules as RU
84
+ n = dict(RU.DEFAULTS["notify"])
85
+ n.update((rules or {}).get("notify") or {})
86
+ out = []
87
+ for i, t in enumerate(n.get("targets") or []):
88
+ ev = t.get("events", ["*"])
89
+ ev = [ev] if isinstance(ev, str) else list(ev)
90
+ out.append({"name": t.get("name") or f"{t['type']}{i + 1}", "type": t["type"], "url_env": t["url_env"],
91
+ "token_env": t.get("token_env") or "", "events": set(EVENTS) if "*" in ev else set(ev)})
92
+ n["targets"] = out
93
+ return n
94
+
95
+
96
+ # ------------------------------------------------------------------ events
97
+ def event(name, title, text="", lines=(), url="", data=None):
98
+ return {"event": name, "title": str(title), "text": str(text), "lines": [str(x) for x in lines][:MAX_LINES],
99
+ "more": max(0, len(lines) - MAX_LINES), "url": str(url or ""), "data": data or {}}
100
+
101
+
102
+ def _is_lifecycle(c):
103
+ return c.get("check", "lifecycle") == "lifecycle" and str(c.get("kind", "")).lower() in LIFECYCLE_KINDS
104
+
105
+
106
+ def _change_line(c):
107
+ return f"{c.get('kind')}: {c.get('item')}" + (f" ({c.get('was')} -> {c.get('now')})" if c.get("was") or c.get("now") else "")
108
+
109
+
110
+ def upkeep_events(rep):
111
+ """upkeep_changes (+ lifecycle) events for an upkeep.run report."""
112
+ changes = [dict(c, check=s["check"]) for s in rep.get("checks", []) for c in s.get("changes", [])]
113
+ evs = []
114
+ if changes:
115
+ url = ((rep.get("github") or rep.get("gitlab") or {}).get("url")) or ""
116
+ evs.append(event("upkeep_changes", f"Library upkeep: {len(changes)} new change(s)",
117
+ ", ".join(f"{s['check']} {len(s['changes'])}" for s in rep.get("checks", []) if s.get("changes")),
118
+ [f"[{c['check']}] {_change_line(c)}" for c in changes], url,
119
+ {"count": len(changes), "changes": changes[:200]}))
120
+ life = [c for c in changes if _is_lifecycle(c)]
121
+ if life:
122
+ evs.append(event("lifecycle", f"Lifecycle: {len(life)} part(s) EOL / NRND / last time buy", "",
123
+ [_change_line(c) for c in life], "", {"parts": life[:200]}))
124
+ return evs
125
+
126
+
127
+ def _plain(reason):
128
+ from . import messages as M
129
+ return M.explain(reason)["text"] if reason else ""
130
+
131
+
132
+ def agent_events(rep, lookup=None):
133
+ """batch_ready / part_held / upkeep_changes / lifecycle events for an agent.run_once report.
134
+ lookup(item id) -> the queue item (for the part number of a failed item)."""
135
+ evs = []
136
+ for b in rep.get("batches") or []:
137
+ n = len(b.get("items") or [])
138
+ where = b.get("pr_url") or ""
139
+ evs.append(event("batch_ready", f"Library agent: batch {b.get('id')} has {n} part(s) to review",
140
+ f"Next: compare the pictures with the datasheets, then approve or reject each part "
141
+ f"(pull request {b.get('pr_action') or 'ready'})." if where else
142
+ f"Next: review the parts in the review queue or in {b.get('folder')} "
143
+ f"(no pull request: {b.get('fallback') or 'not published'}).",
144
+ [], where, {"batch": b.get("id"), "items": b.get("items") or [], "pr_url": b.get("pr_url"),
145
+ "folder": b.get("folder")}))
146
+ failed = rep.get("failed") or []
147
+ if failed:
148
+ def part(f):
149
+ it = (lookup(f["id"]) if lookup else None) or {}
150
+ return it.get("mpn") or it.get("key") or f["id"]
151
+ evs.append(event("part_held", f"Library agent: {len(failed)} part(s) held",
152
+ "They could not be built or failed the independent check. Next: open them in the review queue.",
153
+ [f"{part(f)}: {_plain(f.get('reason', ''))}"[:300] for f in failed], "",
154
+ {"items": [{"id": f["id"], "part": part(f), "reason": f.get("reason")} for f in failed]}))
155
+ found = (rep.get("upkeep") or {}).get("items") or []
156
+ if found:
157
+ evs.append(event("upkeep_changes", f"Library agent upkeep: {len(found)} new finding(s)", "Next: they are in the review queue.",
158
+ [f"[{c.get('check')}] {_change_line(c)}" for c in found], "", {"count": len(found), "changes": found[:200]}))
159
+ life = [c for c in found if _is_lifecycle(c)]
160
+ if life:
161
+ evs.append(event("lifecycle", f"Lifecycle: {len(life)} part(s) EOL / NRND / last time buy", "",
162
+ [_change_line(c) for c in life], "", {"parts": life[:200]}))
163
+ return evs
164
+
165
+
166
+ # ------------------------------------------------------------------ payloads
167
+ def _slack_esc(s):
168
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
169
+
170
+
171
+ def payload(kind, ev):
172
+ lines = ev["lines"] + ([f"... and {ev['more']} more"] if ev.get("more") else [])
173
+ if kind == "slack": # incoming webhook: text (mrkdwn); untrusted text is escaped
174
+ txt = f"*{_slack_esc(ev['title'])}*"
175
+ if ev["text"]:
176
+ txt += f"\n{_slack_esc(ev['text'])}"
177
+ txt += "".join(f"\n• {_slack_esc(x)}" for x in lines)
178
+ if ev["url"]:
179
+ txt += f"\n<{_slack_esc(ev['url'])}|Open>"
180
+ return {"text": txt}
181
+ if kind == "teams": # Teams "workflow" webhook (Power Automate): an Adaptive Card message
182
+ body = [{"type": "TextBlock", "text": ev["title"], "weight": "Bolder", "size": "Medium", "wrap": True}]
183
+ if ev["text"]:
184
+ body.append({"type": "TextBlock", "text": ev["text"], "wrap": True})
185
+ if lines:
186
+ body.append({"type": "TextBlock", "text": "\n".join(f"- {x}" for x in lines), "wrap": True})
187
+ card = {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4",
188
+ "body": body}
189
+ if ev["url"]:
190
+ card["actions"] = [{"type": "Action.OpenUrl", "title": "Open", "url": ev["url"]}]
191
+ return {"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive",
192
+ "contentUrl": None, "content": card}]}
193
+ return {"source": "ecadlib", "event": ev["event"], "title": ev["title"], "text": ev["text"], "lines": ev["lines"],
194
+ "more": ev.get("more", 0), "url": ev["url"], "data": ev["data"], "time": ev.get("time") or _now()}
195
+
196
+
197
+ def _now():
198
+ import datetime as dt
199
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
200
+
201
+
202
+ # ------------------------------------------------------------------ sending
203
+ def http_sender(url, body, headers, timeout):
204
+ """POST body (bytes) -> HTTP status. Network errors raise OSError. Redirects are not followed (urllib would resend the
205
+ Authorization header to any host the 30x names, and turn the POST into a GET): a 3xx is returned as a failure."""
206
+ import urllib.error, urllib.request
207
+ from .forge import no_redirect_opener
208
+ req = urllib.request.Request(url, data=body, method="POST", headers=headers)
209
+ try:
210
+ with no_redirect_opener().open(req, timeout=timeout) as r:
211
+ return r.status
212
+ except urllib.error.HTTPError as e:
213
+ return e.code
214
+
215
+
216
+ def _url_ok(url):
217
+ u = urlsplit(url)
218
+ return u.scheme == "https" and bool(u.hostname) or (u.scheme == "http" and u.hostname in ("localhost", "127.0.0.1", "::1"))
219
+
220
+
221
+ def send(rules, events, env=None, sender=None, sleep=time.sleep, log=None, clock=time.monotonic, max_seconds=MAX_SECONDS):
222
+ """Post each event to every target that wants it. Never raises. -> [{target, event, result, attempts}]."""
223
+ import os
224
+ log = log or (lambda *a: None)
225
+ try:
226
+ cfg = settings(rules)
227
+ except Exception as e: # a broken [notify] must not stop the caller
228
+ log(f"notify: settings unusable: {type(e).__name__}: {e}")
229
+ return [{"target": "-", "event": "-", "result": f"error: {type(e).__name__}"}]
230
+ env = os.environ if env is None else env
231
+ sender = sender or http_sender
232
+ out, start = [], clock()
233
+ retries, timeout = int(cfg.get("retries", 3)), float(cfg.get("timeout_s", 10))
234
+ backoff = float(cfg.get("backoff_s", 2.0))
235
+ for ev in events or []:
236
+ ev = dict(ev, time=ev.get("time") or _now())
237
+ for t in cfg["targets"]:
238
+ if ev["event"] not in t["events"]:
239
+ continue
240
+ rec = {"target": t["name"], "event": ev["event"], "attempts": 0}
241
+ out.append(rec)
242
+ url = (env.get(t["url_env"]) or "").strip()
243
+ if not url:
244
+ rec["result"] = f"skipped: ${t['url_env']} is not set"
245
+ log(f"notify: {t['name']}: {rec['result']}")
246
+ continue
247
+ if not _url_ok(url):
248
+ rec["result"] = f"skipped: ${t['url_env']} is not an https:// URL"
249
+ log(f"notify: {t['name']}: {rec['result']}")
250
+ continue
251
+ host = urlsplit(url).hostname
252
+ headers = {"Content-Type": "application/json", "User-Agent": "ecadlib-notify"}
253
+ if t["token_env"]:
254
+ tok = env.get(t["token_env"])
255
+ if not tok:
256
+ rec["result"] = f"skipped: ${t['token_env']} is not set"
257
+ log(f"notify: {t['name']}: {rec['result']}")
258
+ continue
259
+ headers["Authorization"] = f"Bearer {tok}"
260
+ try:
261
+ body = json.dumps(payload(t["type"], ev), ensure_ascii=False, default=str).encode()
262
+ except Exception as e:
263
+ rec["result"] = f"error: payload: {type(e).__name__}: {e}"
264
+ continue
265
+ if clock() - start > max_seconds: # slow or hanging hooks: the agent waits no longer
266
+ rec["result"] = f"skipped: the {max_seconds:g} s notify time budget is used up"
267
+ log(f"notify: {t['name']}: {rec['result']}")
268
+ continue
269
+ for attempt in range(retries + 1):
270
+ rec["attempts"] = attempt + 1
271
+ try:
272
+ status = int(sender(url, body, headers, timeout))
273
+ err = None if 200 <= status < 300 else f"HTTP {status}"
274
+ transient = status == 429 or status >= 500
275
+ except Exception as e: # network, DNS, TLS, timeout: try again
276
+ err, transient = f"{type(e).__name__}", True
277
+ if err is None:
278
+ rec["result"] = "sent"
279
+ break
280
+ rec["result"] = f"failed: {err} from {host}"
281
+ wait = backoff * (2 ** attempt)
282
+ if not transient or attempt == retries or clock() - start + wait > max_seconds:
283
+ log(f"notify: {t['name']}: {rec['result']} after {attempt + 1} attempt(s)")
284
+ break
285
+ sleep(wait)
286
+ return out
287
+
288
+
289
+ # ------------------------------------------------------------------ CLI
290
+ def main(argv=None):
291
+ from . import rules as RU
292
+ ap = argparse.ArgumentParser(prog="ecadlib notify", description="webhook notifications ([notify] in ecadlib.toml)")
293
+ sub = ap.add_subparsers(dest="cmd", required=True)
294
+ for name, h in (("show", "targets, their events and whether their environment variables are set"),
295
+ ("test", "send a test message to the configured targets")):
296
+ p = sub.add_parser(name, help=h)
297
+ p.add_argument("--rules", help="ecadlib.toml (default: found from here)")
298
+ if name == "test":
299
+ p.add_argument("--event", choices=EVENTS, default="batch_ready")
300
+ p.add_argument("--target", help="only this target (name)")
301
+ a = ap.parse_args(argv)
302
+ import os
303
+ rules = RU.load(a.rules)
304
+ cfg = settings(rules)
305
+ if not cfg["targets"]:
306
+ print(f"no [notify] targets in {rules['_file'] or 'the rules (no ecadlib.toml found)'}")
307
+ return 0 if a.cmd == "show" else 1
308
+ if a.cmd == "show":
309
+ for t in cfg["targets"]:
310
+ st = "set" if os.environ.get(t["url_env"]) else "NOT SET"
311
+ print(f"{t['name']:<20} {t['type']:<6} ${t['url_env']} ({st}) events: {', '.join(sorted(t['events']))}")
312
+ return 0
313
+ if a.target:
314
+ rules = dict(rules, notify=dict(rules["notify"], targets=[ # the name kept: alone it would be renumbered 1
315
+ dict(t, name=t.get("name") or f"{t['type']}{i + 1}") for i, t in enumerate(rules["notify"]["targets"])
316
+ if (t.get("name") or f"{t['type']}{i + 1}") == a.target]))
317
+ if not rules["notify"]["targets"]:
318
+ print(f"no target named {a.target!r}", file=sys.stderr)
319
+ return 1
320
+ ev = event(a.event, f"ecadlib test notification ({a.event})", "If you can read this, the webhook works.",
321
+ ["sent by `ecadlib notify test`"])
322
+ res = send(rules, [ev], log=lambda m: print(m, file=sys.stderr))
323
+ for r in res:
324
+ print(f"{r['target']:<20} {r['result']}")
325
+ return 0 if res and all(r["result"] == "sent" for r in res) else 1
326
+
327
+
328
+ if __name__ == "__main__":
329
+ sys.exit(main())