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.
@@ -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,9 @@
1
+ """Interloper Slack integration: notification hook and connection."""
2
+
3
+ from interloper_slack.connection import SlackConnection
4
+ from interloper_slack.hook import SlackHook
5
+
6
+ __all__ = [
7
+ "SlackConnection",
8
+ "SlackHook",
9
+ ]
@@ -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)