interloper-slack 0.54.0__tar.gz
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.
- interloper_slack-0.54.0/PKG-INFO +92 -0
- interloper_slack-0.54.0/README.md +82 -0
- interloper_slack-0.54.0/pyproject.toml +44 -0
- interloper_slack-0.54.0/pyproject.toml.orig +36 -0
- interloper_slack-0.54.0/src/interloper_slack/__init__.py +9 -0
- interloper_slack-0.54.0/src/interloper_slack/connection.py +123 -0
- interloper_slack-0.54.0/src/interloper_slack/hook.py +105 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: interloper-slack
|
|
3
|
+
Version: 0.54.0
|
|
4
|
+
Summary: Interloper Slack integration: notification hook and connection
|
|
5
|
+
Author: Guillaume Onfroy
|
|
6
|
+
Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
|
|
7
|
+
Requires-Dist: interloper-core
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# interloper-slack
|
|
12
|
+
|
|
13
|
+
Slack notifications for interloper runs: a `SlackHook` that posts a run
|
|
14
|
+
outcome to a channel, and the `SlackConnection` that holds the bot token.
|
|
15
|
+
|
|
16
|
+
The hook is the notification counterpart to core's `WebhookHook` — same
|
|
17
|
+
events, but the payload is a message a human reads rather than a document a
|
|
18
|
+
service parses.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
1. Create a Slack app (**From scratch**) at
|
|
23
|
+
[api.slack.com/apps](https://api.slack.com/apps).
|
|
24
|
+
2. Under **OAuth & Permissions**, add these bot token scopes:
|
|
25
|
+
- `chat:write` — post the notification
|
|
26
|
+
- `channels:read`, `groups:read` — populate the channel picker
|
|
27
|
+
3. **Install to Workspace** and copy the bot user token (`xoxb-…`).
|
|
28
|
+
4. Invite the bot to each channel it posts to (`/invite @your-app`). Slack
|
|
29
|
+
rejects `chat.postMessage` with `not_in_channel` otherwise.
|
|
30
|
+
|
|
31
|
+
One connection serves the whole workspace; each hook picks its own channel.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import interloper as il
|
|
37
|
+
from interloper_slack import SlackConnection, SlackHook
|
|
38
|
+
|
|
39
|
+
hook = SlackHook(
|
|
40
|
+
connection=SlackConnection(bot_token="xoxb-..."),
|
|
41
|
+
channel="C0123456789",
|
|
42
|
+
watches=[my_source],
|
|
43
|
+
events=["run_failed"],
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The token also loads from the environment (`SLACK_BOT_TOKEN`), so
|
|
48
|
+
`SlackConnection()` works with no arguments.
|
|
49
|
+
|
|
50
|
+
`events` defaults to `["run_failed"]` like every hook — the alert most teams
|
|
51
|
+
want. Add `"run_completed"` for a full-chatter channel.
|
|
52
|
+
|
|
53
|
+
In a deployed instance you configure this through the UI instead: add a Slack
|
|
54
|
+
connection, then a Slack hook watching the source or job you care about. The
|
|
55
|
+
scheduler's hook evaluator fires it on terminal runs.
|
|
56
|
+
|
|
57
|
+
## Message shape
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
❌ *Facebook Ads* failed
|
|
61
|
+
Run `9f3c…` · partition `2026-07-30`
|
|
62
|
+
```
|
|
63
|
+
```
|
|
64
|
+
HTTPStatusError: 429 Too Many Requests
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The headline also travels as the message's `text`, so notification previews
|
|
68
|
+
and screen readers get the outcome without parsing blocks.
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
Every Slack call goes through the connection's `client` — a `cached_property`
|
|
73
|
+
`il.RESTClient` with `il.HTTPBearerAuth`, like every other connection in the
|
|
74
|
+
workspace. That client owns the base URL, the token, and the connection pool,
|
|
75
|
+
so callers name a path and nothing else; the channel picker paginates with
|
|
76
|
+
`il.JSONCursorPaginator` over Slack's `response_metadata.next_cursor`.
|
|
77
|
+
|
|
78
|
+
The client is **sync**, where most connections' are async. The two consumers
|
|
79
|
+
are a hook firing (sync by contract) and a form lookup — neither has
|
|
80
|
+
independent requests to overlap, and the API process runs sync providers in a
|
|
81
|
+
thread, so it never blocks the event loop.
|
|
82
|
+
|
|
83
|
+
The one thing the framework can't cover is that Slack answers a *rejected*
|
|
84
|
+
call with HTTP 200 and `{"ok": false, "error": "..."}`, so `raise_for_status()`
|
|
85
|
+
alone lets failures pass silently. Each call site checks `ok` itself and raises
|
|
86
|
+
`RuntimeError` with Slack's own error code — the same shape as the other
|
|
87
|
+
connections that face an in-body error flag. For the paginated picker that
|
|
88
|
+
check lives in the `data_selector`, since `paginate` only raises for HTTP
|
|
89
|
+
status.
|
|
90
|
+
|
|
91
|
+
A failed firing is recorded on the hook's firing claim (as `hook_failed`) and
|
|
92
|
+
is not retried.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# interloper-slack
|
|
2
|
+
|
|
3
|
+
Slack notifications for interloper runs: a `SlackHook` that posts a run
|
|
4
|
+
outcome to a channel, and the `SlackConnection` that holds the bot token.
|
|
5
|
+
|
|
6
|
+
The hook is the notification counterpart to core's `WebhookHook` — same
|
|
7
|
+
events, but the payload is a message a human reads rather than a document a
|
|
8
|
+
service parses.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
1. Create a Slack app (**From scratch**) at
|
|
13
|
+
[api.slack.com/apps](https://api.slack.com/apps).
|
|
14
|
+
2. Under **OAuth & Permissions**, add these bot token scopes:
|
|
15
|
+
- `chat:write` — post the notification
|
|
16
|
+
- `channels:read`, `groups:read` — populate the channel picker
|
|
17
|
+
3. **Install to Workspace** and copy the bot user token (`xoxb-…`).
|
|
18
|
+
4. Invite the bot to each channel it posts to (`/invite @your-app`). Slack
|
|
19
|
+
rejects `chat.postMessage` with `not_in_channel` otherwise.
|
|
20
|
+
|
|
21
|
+
One connection serves the whole workspace; each hook picks its own channel.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import interloper as il
|
|
27
|
+
from interloper_slack import SlackConnection, SlackHook
|
|
28
|
+
|
|
29
|
+
hook = SlackHook(
|
|
30
|
+
connection=SlackConnection(bot_token="xoxb-..."),
|
|
31
|
+
channel="C0123456789",
|
|
32
|
+
watches=[my_source],
|
|
33
|
+
events=["run_failed"],
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The token also loads from the environment (`SLACK_BOT_TOKEN`), so
|
|
38
|
+
`SlackConnection()` works with no arguments.
|
|
39
|
+
|
|
40
|
+
`events` defaults to `["run_failed"]` like every hook — the alert most teams
|
|
41
|
+
want. Add `"run_completed"` for a full-chatter channel.
|
|
42
|
+
|
|
43
|
+
In a deployed instance you configure this through the UI instead: add a Slack
|
|
44
|
+
connection, then a Slack hook watching the source or job you care about. The
|
|
45
|
+
scheduler's hook evaluator fires it on terminal runs.
|
|
46
|
+
|
|
47
|
+
## Message shape
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
❌ *Facebook Ads* failed
|
|
51
|
+
Run `9f3c…` · partition `2026-07-30`
|
|
52
|
+
```
|
|
53
|
+
```
|
|
54
|
+
HTTPStatusError: 429 Too Many Requests
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The headline also travels as the message's `text`, so notification previews
|
|
58
|
+
and screen readers get the outcome without parsing blocks.
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
Every Slack call goes through the connection's `client` — a `cached_property`
|
|
63
|
+
`il.RESTClient` with `il.HTTPBearerAuth`, like every other connection in the
|
|
64
|
+
workspace. That client owns the base URL, the token, and the connection pool,
|
|
65
|
+
so callers name a path and nothing else; the channel picker paginates with
|
|
66
|
+
`il.JSONCursorPaginator` over Slack's `response_metadata.next_cursor`.
|
|
67
|
+
|
|
68
|
+
The client is **sync**, where most connections' are async. The two consumers
|
|
69
|
+
are a hook firing (sync by contract) and a form lookup — neither has
|
|
70
|
+
independent requests to overlap, and the API process runs sync providers in a
|
|
71
|
+
thread, so it never blocks the event loop.
|
|
72
|
+
|
|
73
|
+
The one thing the framework can't cover is that Slack answers a *rejected*
|
|
74
|
+
call with HTTP 200 and `{"ok": false, "error": "..."}`, so `raise_for_status()`
|
|
75
|
+
alone lets failures pass silently. Each call site checks `ok` itself and raises
|
|
76
|
+
`RuntimeError` with Slack's own error code — the same shape as the other
|
|
77
|
+
connections that face an in-body error flag. For the paginated picker that
|
|
78
|
+
check lives in the `data_selector`, since `paginate` only raises for HTTP
|
|
79
|
+
status.
|
|
80
|
+
|
|
81
|
+
A failed firing is recorded on the hook's firing claim (as `hook_failed`) and
|
|
82
|
+
is not retried.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "interloper-slack"
|
|
3
|
+
version = "0.54.0"
|
|
4
|
+
description = "Interloper Slack integration: notification hook and connection"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["interloper-core"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Guillaume Onfroy"
|
|
11
|
+
email = "guillaume@digitlcloud.com"
|
|
12
|
+
|
|
13
|
+
[project.entry-points."interloper.components"]
|
|
14
|
+
slack = "interloper_slack"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.5,<0.12"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources.interloper-core]
|
|
21
|
+
workspace = true
|
|
22
|
+
|
|
23
|
+
[tool.ruff]
|
|
24
|
+
line-length = 120
|
|
25
|
+
|
|
26
|
+
[tool.ruff.lint]
|
|
27
|
+
extend-select = [
|
|
28
|
+
"E",
|
|
29
|
+
"I",
|
|
30
|
+
"UP",
|
|
31
|
+
"ANN001",
|
|
32
|
+
"ANN201",
|
|
33
|
+
"ANN202",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint.per-file-ignores]
|
|
37
|
+
"__init__.py" = [
|
|
38
|
+
"F401",
|
|
39
|
+
"F403",
|
|
40
|
+
]
|
|
41
|
+
"tests/**" = [
|
|
42
|
+
"ANN",
|
|
43
|
+
"F811",
|
|
44
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ###############
|
|
2
|
+
# PROJECT / UV
|
|
3
|
+
# ###############
|
|
4
|
+
[project]
|
|
5
|
+
name = "interloper-slack"
|
|
6
|
+
version = "0.54.0"
|
|
7
|
+
description = "Interloper Slack integration: notification hook and connection"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"interloper-core",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.entry-points."interloper.components"]
|
|
16
|
+
slack = "interloper_slack"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.5,<0.12"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
interloper-core = { workspace = true }
|
|
24
|
+
|
|
25
|
+
# ###############
|
|
26
|
+
# RUFF
|
|
27
|
+
# ###############
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 120
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint.per-file-ignores]
|
|
35
|
+
"__init__.py" = ["F401", "F403"]
|
|
36
|
+
"tests/**" = ["ANN", "F811"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Slack connection resource holding a bot token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from interloper.connection import Connection, connection
|
|
10
|
+
from interloper.resource.fields import SecretField, fetch_field_provider
|
|
11
|
+
from interloper.rest import HTTPBearerAuth, JSONCursorPaginator, RESTClient
|
|
12
|
+
from pydantic_settings import SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
API_BASE = "https://slack.com/api"
|
|
15
|
+
|
|
16
|
+
#: Slack caps ``conversations.list`` at 1000 per page.
|
|
17
|
+
_PAGE_LIMIT = 1000
|
|
18
|
+
|
|
19
|
+
#: Both channel visibilities a bot can post to once invited.
|
|
20
|
+
_CHANNEL_TYPES = "public_channel,private_channel"
|
|
21
|
+
|
|
22
|
+
#: Every call here serves an operator waiting on a form or a firing hook;
|
|
23
|
+
#: neither is worth blocking on longer than this.
|
|
24
|
+
_TIMEOUT = 30.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _channels(response: httpx.Response) -> list[dict[str, Any]]:
|
|
28
|
+
"""Select one page of channels, checking Slack's in-body ``ok`` flag.
|
|
29
|
+
|
|
30
|
+
``paginate`` only raises for HTTP status, so the selector is where a
|
|
31
|
+
rejected page surfaces instead of a confusing miss on the ``channels``
|
|
32
|
+
key — Slack answers a refusal with 200 and ``ok: false``.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The page's raw channel objects.
|
|
36
|
+
"""
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
body = response.json()
|
|
39
|
+
if not body.get("ok"):
|
|
40
|
+
raise RuntimeError(f"Slack API error: {body.get('error')}")
|
|
41
|
+
return body.get("channels", [])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@connection(
|
|
45
|
+
key="slack_connection",
|
|
46
|
+
name="Slack",
|
|
47
|
+
icon="devicon:slack",
|
|
48
|
+
tags=["Communication"],
|
|
49
|
+
)
|
|
50
|
+
class SlackConnection(Connection):
|
|
51
|
+
"""Connection resource holding a Slack bot token.
|
|
52
|
+
|
|
53
|
+
The token is the whole credential, so one connection serves every hook in
|
|
54
|
+
the workspace and each hook picks its own channel. Create a Slack app,
|
|
55
|
+
give its bot the ``chat:write`` scope (plus ``channels:read`` /
|
|
56
|
+
``groups:read`` for the channel picker), install it, and paste the
|
|
57
|
+
``xoxb-`` token.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
model_config = SettingsConfigDict(env_prefix="slack_")
|
|
61
|
+
|
|
62
|
+
bot_token: str = SecretField(
|
|
63
|
+
label="Bot token",
|
|
64
|
+
description="Slack bot user OAuth token (xoxb-…)",
|
|
65
|
+
info=(
|
|
66
|
+
"From your Slack app's OAuth & Permissions page. Needs the chat:write scope, "
|
|
67
|
+
"plus channels:read and groups:read for the channel picker."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@cached_property
|
|
72
|
+
def client(self) -> RESTClient:
|
|
73
|
+
"""The Slack Web API client every caller shares.
|
|
74
|
+
|
|
75
|
+
Sync, unlike most connections' clients: the consumers are a hook
|
|
76
|
+
firing (sync by contract) and a form lookup, neither of which has
|
|
77
|
+
independent requests to overlap. The API process runs sync providers
|
|
78
|
+
in a thread, so this does not block its event loop.
|
|
79
|
+
"""
|
|
80
|
+
return RESTClient(API_BASE, auth=HTTPBearerAuth(self.bot_token), timeout=_TIMEOUT)
|
|
81
|
+
|
|
82
|
+
@fetch_field_provider
|
|
83
|
+
def channels(self) -> list[dict[str, str]]:
|
|
84
|
+
"""List the workspace channels this token can see.
|
|
85
|
+
|
|
86
|
+
Backs the hook's ``channel`` ``FetchField``. Archived channels are
|
|
87
|
+
excluded — posting to one fails — and each option is labelled with a
|
|
88
|
+
leading ``#`` so the picker reads the way the channel does in Slack.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Channel options with ``id`` and a display ``name``.
|
|
92
|
+
"""
|
|
93
|
+
pages = self.client.paginate(
|
|
94
|
+
"/conversations.list",
|
|
95
|
+
JSONCursorPaginator(cursor_path="response_metadata.next_cursor", cursor_param="cursor"),
|
|
96
|
+
params={
|
|
97
|
+
"types": _CHANNEL_TYPES,
|
|
98
|
+
"limit": str(_PAGE_LIMIT),
|
|
99
|
+
"exclude_archived": "true",
|
|
100
|
+
},
|
|
101
|
+
data_selector=_channels,
|
|
102
|
+
)
|
|
103
|
+
results = [{"id": channel["id"], "name": f"#{channel['name']}"} for page in pages for channel in page]
|
|
104
|
+
return sorted(results, key=lambda c: c["name"].lower())
|
|
105
|
+
|
|
106
|
+
def check(self) -> bool:
|
|
107
|
+
"""Prove the token works via ``auth.test``.
|
|
108
|
+
|
|
109
|
+
``auth.test`` needs no scope beyond a valid token, so it isolates a
|
|
110
|
+
bad credential from a missing ``channels:read`` grant.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True — an invalid token raises instead.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
RuntimeError: If Slack rejects the token.
|
|
117
|
+
"""
|
|
118
|
+
response = self.client.post("/auth.test")
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
body = response.json()
|
|
121
|
+
if not body.get("ok"):
|
|
122
|
+
raise RuntimeError(f"Slack API error: {body.get('error')}")
|
|
123
|
+
return True
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Slack hook: post a run outcome to a channel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from interloper.errors import ConfigError
|
|
8
|
+
from interloper.hook import Hook, HookContext
|
|
9
|
+
from interloper.resource.fields import FetchField, InputField
|
|
10
|
+
|
|
11
|
+
from interloper_slack.connection import SlackConnection
|
|
12
|
+
|
|
13
|
+
#: Event type → (emoji, past-tense verb) for the headline.
|
|
14
|
+
_OUTCOMES: dict[str, tuple[str, str]] = {
|
|
15
|
+
"run_completed": (":white_check_mark:", "completed"),
|
|
16
|
+
"run_failed": (":x:", "failed"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlackHook(Hook):
|
|
21
|
+
"""Posts a run outcome to a Slack channel.
|
|
22
|
+
|
|
23
|
+
The notification counterpart to ``WebhookHook``: same events, but the
|
|
24
|
+
payload is a message a human reads rather than a document a service
|
|
25
|
+
parses. Defaults to ``run_failed`` like every hook, which is the alert
|
|
26
|
+
most teams want; add ``run_completed`` for a full-chatter channel.
|
|
27
|
+
|
|
28
|
+
The bot must be a member of the target channel — Slack rejects
|
|
29
|
+
``chat.postMessage`` with ``not_in_channel`` otherwise, and the failure
|
|
30
|
+
is recorded on the firing claim rather than retried.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: ClassVar[str] = "Slack"
|
|
34
|
+
icon: ClassVar[str] = "devicon:slack"
|
|
35
|
+
tags: ClassVar[list[str]] = ["Communication"]
|
|
36
|
+
|
|
37
|
+
connection: SlackConnection
|
|
38
|
+
|
|
39
|
+
channel: str = FetchField(
|
|
40
|
+
provider="connection.channels",
|
|
41
|
+
label_key="name",
|
|
42
|
+
value_key="id",
|
|
43
|
+
description="Channel that receives the notification",
|
|
44
|
+
discriminator=True,
|
|
45
|
+
)
|
|
46
|
+
timeout: float = InputField(default=10.0, description="Request timeout in seconds")
|
|
47
|
+
|
|
48
|
+
def fire(self, context: HookContext) -> None:
|
|
49
|
+
"""Post the event as a message to the configured channel.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ConfigError: If no Slack connection is attached.
|
|
53
|
+
RuntimeError: If Slack rejects the message — it answers a refusal
|
|
54
|
+
with 200 and ``ok: false``, so the status alone is not enough.
|
|
55
|
+
"""
|
|
56
|
+
if self.connection is None:
|
|
57
|
+
raise ConfigError(f"SlackHook '{self.id}' fired without a Slack connection")
|
|
58
|
+
|
|
59
|
+
response = self.connection.client.post(
|
|
60
|
+
"/chat.postMessage",
|
|
61
|
+
json=self._message(context),
|
|
62
|
+
timeout=self.timeout,
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
body = response.json()
|
|
66
|
+
if not body.get("ok"):
|
|
67
|
+
raise RuntimeError(f"Slack API error: {body.get('error')}")
|
|
68
|
+
|
|
69
|
+
def _message(self, context: HookContext) -> dict[str, Any]:
|
|
70
|
+
"""Build the ``chat.postMessage`` payload.
|
|
71
|
+
|
|
72
|
+
``text`` carries the headline on its own so notification previews and
|
|
73
|
+
screen readers get the outcome without parsing blocks.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The JSON-able message payload.
|
|
77
|
+
"""
|
|
78
|
+
emoji, verb = _OUTCOMES.get(context.event_type, (":bell:", context.event_type))
|
|
79
|
+
subject = context.metadata.get("component_name") or context.component_id
|
|
80
|
+
headline = f"{emoji} *{subject}* {verb}"
|
|
81
|
+
|
|
82
|
+
lines = [headline]
|
|
83
|
+
if details := self._details(context):
|
|
84
|
+
lines.append(details)
|
|
85
|
+
if error := context.metadata.get("error"):
|
|
86
|
+
lines.append(f"```{error}```")
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"channel": self.channel,
|
|
90
|
+
"text": f"{subject} {verb}",
|
|
91
|
+
"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def _details(self, context: HookContext) -> str:
|
|
95
|
+
"""Render the run/partition context line.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The line, or ``""`` when the context carries neither.
|
|
99
|
+
"""
|
|
100
|
+
parts = []
|
|
101
|
+
if context.run_id:
|
|
102
|
+
parts.append(f"Run `{context.run_id}`")
|
|
103
|
+
if context.partition_date:
|
|
104
|
+
parts.append(f"partition `{context.partition_date}`")
|
|
105
|
+
return " · ".join(parts)
|