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/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("&", "&").replace("<", "<").replace(">", ">")
|
|
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())
|