nui-python-shared-utils 2.1__tar.gz → 2.3__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.
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/PKG-INFO +1 -1
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/powertools-integration.md +16 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/slack-integration.md +150 -45
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/__init__.py +2 -1
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/powertools_helpers.py +29 -2
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_client.py +471 -121
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_powertools_helpers.py +147 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_slack_client.py +707 -1
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.editorconfig +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/ci.yml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/publish.yml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/test.yml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.markdownlint-cli2.yaml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/AGENTS.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/CLAUDE.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/CONTRIBUTING.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/LICENSE +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/MANIFEST.in +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/README.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/README.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-001-structured-logging-field-vocabulary.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-002-keep-shared-utilities-generic.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-003-lazy-load-integrations.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-004-credential-resolution-precedence.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-005-explicit-secrets-manager-region.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-006-snowflake-sql-api-client.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-007-elasticsearch-client-version-range.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-008-stdlib-zoneinfo-over-pytz.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/README.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/development/testing.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/configuration.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/installation.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/quickstart.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/cli-tools.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/elasticsearch-integration.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/jwt-authentication.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/lambda-utilities.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/llm-integration.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/log-processing.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/shared-types.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/snowflake-integration.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/structured-logging-contract.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/mypy.ini +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_python_shared_utils.egg-info/SOURCES.txt +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/base_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/cli.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/cloudwatch_metrics.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/config.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/db_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/error_handler.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/es_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/es_query_builder.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/jwt_auth.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/lambda_helpers.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/llm.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/log_processors.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/__init__.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/binding.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/fields.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/redaction.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/secrets_helper.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_formatter.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/__init__.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/channel_creator.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/channel_definitions.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/setup_helpers.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/snowflake_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/timezone.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/utils.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/pyproject.toml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/pytest.ini +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/README.md +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/pyproject.toml +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/setup.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/requirements-test.txt +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/scripts/bench_imports.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/setup.cfg +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/setup.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/__init__.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_aws_utils.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_base_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_cloudwatch_metrics.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_config.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_db_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_error_handler.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_client_compat.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_query_builder.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_jwt_auth.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_lambda_helpers.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_lazy_imports.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_llm.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_log_processors.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_logging_binding.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_logging_fields.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_redaction.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_secrets_helper.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_slack_formatter.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_snowflake_client.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_timezone.py +0 -0
- {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nui-python-shared-utils
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3
|
|
4
4
|
Summary: Shared Python utilities for AWS Lambda, CLI tools, and agents with Slack, Elasticsearch, and monitoring integrations
|
|
5
5
|
Home-page: https://github.com/nuimarkets/nui-python-shared-utils
|
|
6
6
|
Author: NUI Markets
|
{nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/powertools-integration.md
RENAMED
|
@@ -179,6 +179,22 @@ def powertools_handler(
|
|
|
179
179
|
"""
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### When the Handler Raises
|
|
183
|
+
|
|
184
|
+
The decorator logs the exception and sends the Slack alert (if configured), then:
|
|
185
|
+
|
|
186
|
+
- **SQS events** (`Records[0].eventSource == "aws:sqs"`, checked before the handler runs):
|
|
187
|
+
re-raises. A returned error dict tells the event source mapping the batch succeeded (with
|
|
188
|
+
`ReportBatchItemFailures`, a response without `batchItemFailures` means every record did),
|
|
189
|
+
so the batch would be deleted. Re-raising leaves it on the queue for redelivery; it moves
|
|
190
|
+
to a DLQ after `maxReceiveCount` only if the queue has a redrive policy. The whole batch
|
|
191
|
+
comes back, records the handler already processed included, so a handler with
|
|
192
|
+
`batchSize` above 1 must be idempotent or catch per record. On a FIFO queue the message
|
|
193
|
+
group waits until the batch succeeds or redrives, and the Slack alert fires on every
|
|
194
|
+
receive. For per-record retries, enable `ReportBatchItemFailures` on the mapping and
|
|
195
|
+
return `{"batchItemFailures": [...]}` from the handler; the decorator passes it through.
|
|
196
|
+
- **Every other event**: returns `{"statusCode": 500, "body": "Internal Server Error"}`.
|
|
197
|
+
|
|
182
198
|
### Basic Usage (Logging Only)
|
|
183
199
|
|
|
184
200
|
```python
|
{nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/slack-integration.md
RENAMED
|
@@ -94,24 +94,84 @@ slack.send_message(
|
|
|
94
94
|
)
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
### Two families of write method
|
|
98
|
+
|
|
99
|
+
Every write has two forms, differing only in what they hand back:
|
|
100
|
+
|
|
101
|
+
| Operation | Returns a bool, swallows errors | Returns Slack's answer, raises |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Post a message | `send_message` | `post_message` |
|
|
104
|
+
| Reply in a thread | `send_thread_reply` | `post_thread_reply` |
|
|
105
|
+
| Edit a message | `update_message` | `post_update` |
|
|
106
|
+
| Upload a file | `send_file` | `post_file` |
|
|
107
|
+
| Add a reaction | `add_reaction` | (none) |
|
|
108
|
+
|
|
109
|
+
Reach for a `post_*` method when you need the message's `ts`, which is the only
|
|
110
|
+
handle a thread reply, edit or reaction accepts, or when you need the reason
|
|
111
|
+
behind a failure. Keep the bool-returning ones when a failed notification should
|
|
112
|
+
not take the caller down with it.
|
|
113
|
+
|
|
114
|
+
The `post_*` methods return a `SentMessage` (`ok`, `ts`, `channel`, and the raw
|
|
115
|
+
`response`), except `post_file`, which returns the upload's completion response
|
|
116
|
+
because an upload has no single message `ts`.
|
|
117
|
+
|
|
97
118
|
### Message Threading
|
|
98
119
|
|
|
120
|
+
Threading needs the parent message's `ts`, so post it with `post_message`
|
|
121
|
+
rather than `send_message`: the two take the same arguments and add the same
|
|
122
|
+
header, but `send_message` answers a bare `True`/`False` and drops the `ts`.
|
|
123
|
+
|
|
99
124
|
```python
|
|
100
|
-
# Send initial message
|
|
101
|
-
|
|
125
|
+
# Send initial message, keeping Slack's response
|
|
126
|
+
sent = slack.post_message(
|
|
102
127
|
channel="#support",
|
|
103
128
|
text="New support ticket received"
|
|
104
129
|
)
|
|
105
130
|
|
|
106
131
|
# Reply in thread
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
text="Ticket assigned to engineering team"
|
|
111
|
-
thread_ts=thread_ts
|
|
132
|
+
slack.send_thread_reply(
|
|
133
|
+
channel=sent.channel,
|
|
134
|
+
thread_ts=sent.ts,
|
|
135
|
+
text="Ticket assigned to engineering team"
|
|
112
136
|
)
|
|
113
137
|
```
|
|
114
138
|
|
|
139
|
+
`post_message` returns a `SentMessage` with `ok`, `ts`, `channel` and the raw
|
|
140
|
+
`response`.
|
|
141
|
+
|
|
142
|
+
Like every `post_*` method here, it raises rather than swallowing, and it raises
|
|
143
|
+
on *every* failure including Slack's own rejections: `slack_sdk` validates
|
|
144
|
+
each response, so `{"ok": false, ...}` arrives as a `SlackApiError` carrying the
|
|
145
|
+
error code, status and retry headers on `err.response`. A returned `SentMessage`
|
|
146
|
+
therefore always succeeded. Handle failure with `except`, not with `if sent:`,
|
|
147
|
+
and catch broadly rather than only `SlackApiError`:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from slack_sdk.errors import SlackApiError
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
sent = slack.post_message(channel="#support", text="New support ticket")
|
|
154
|
+
except SlackApiError as e:
|
|
155
|
+
log.error("Slack rejected the post: %s", e.response["error"])
|
|
156
|
+
raise
|
|
157
|
+
except Exception:
|
|
158
|
+
log.exception("Slack post failed before Slack answered")
|
|
159
|
+
raise
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The same `ts` addresses `update_message` and `add_reaction`:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
sent = slack.post_message(channel="#deployments", text="Deploy started")
|
|
166
|
+
slack.update_message(channel=sent.channel, ts=sent.ts, text="Deploy finished")
|
|
167
|
+
slack.add_reaction(channel=sent.channel, ts=sent.ts, emoji="white_check_mark")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Pass `sent.channel`, not the value you posted to. `chat.postMessage` accepts a
|
|
171
|
+
channel name, while `chat.update` and `reactions.add` want the id, and a post
|
|
172
|
+
addressed to a user id resolves to a DM channel with a different id again. Both
|
|
173
|
+
follow-up calls swallow their failures, so getting this wrong is silent.
|
|
174
|
+
|
|
115
175
|
### Direct Messages
|
|
116
176
|
|
|
117
177
|
```python
|
|
@@ -121,14 +181,20 @@ slack.send_message(
|
|
|
121
181
|
text="Your report is ready"
|
|
122
182
|
)
|
|
123
183
|
|
|
124
|
-
# Send to user by email
|
|
125
|
-
|
|
184
|
+
# Send to a user found by email. This client does not look users up, so
|
|
185
|
+
# go through the underlying Slack SDK for that part.
|
|
186
|
+
from slack_sdk import WebClient
|
|
187
|
+
|
|
188
|
+
lookup = WebClient(token=bot_token).users_lookupByEmail(email="user@example.com")
|
|
126
189
|
slack.send_message(
|
|
127
|
-
channel=user[
|
|
190
|
+
channel=lookup["user"]["id"],
|
|
128
191
|
text="Direct message content"
|
|
129
192
|
)
|
|
130
193
|
```
|
|
131
194
|
|
|
195
|
+
A post addressed to a user resolves to a DM channel with an id of its own, so
|
|
196
|
+
keep `sent.channel` from `post_message` if you intend to thread, edit or react.
|
|
197
|
+
|
|
132
198
|
## Rich Message Formatting
|
|
133
199
|
|
|
134
200
|
### Using SlackBlockBuilder
|
|
@@ -154,7 +220,7 @@ blocks = (builder
|
|
|
154
220
|
)
|
|
155
221
|
|
|
156
222
|
# Send formatted message
|
|
157
|
-
slack.send_message(channel="#deployments", blocks=blocks)
|
|
223
|
+
slack.send_message(channel="#deployments", text="Deployment update", blocks=blocks)
|
|
158
224
|
```
|
|
159
225
|
|
|
160
226
|
### Block Types
|
|
@@ -235,6 +301,7 @@ def send_execution_report(event, context, results):
|
|
|
235
301
|
|
|
236
302
|
slack.send_message(
|
|
237
303
|
channel="#lambda-executions",
|
|
304
|
+
text=f"{context.function_name} execution report",
|
|
238
305
|
blocks=blocks
|
|
239
306
|
)
|
|
240
307
|
```
|
|
@@ -268,16 +335,17 @@ def send_error_alert(error: Exception, context: dict):
|
|
|
268
335
|
)
|
|
269
336
|
|
|
270
337
|
# Send to alerts channel
|
|
271
|
-
|
|
338
|
+
sent = slack.post_message(
|
|
272
339
|
channel="#alerts-critical",
|
|
340
|
+
text="Critical error",
|
|
273
341
|
blocks=blocks
|
|
274
342
|
)
|
|
275
343
|
|
|
276
344
|
# Add stack trace in thread to avoid clutter
|
|
277
|
-
slack.
|
|
278
|
-
channel=
|
|
279
|
-
|
|
280
|
-
|
|
345
|
+
slack.send_thread_reply(
|
|
346
|
+
channel=sent.channel,
|
|
347
|
+
thread_ts=sent.ts,
|
|
348
|
+
text=f"```{error_details}```"
|
|
281
349
|
)
|
|
282
350
|
```
|
|
283
351
|
|
|
@@ -310,51 +378,75 @@ def send_processing_summary(stats: dict):
|
|
|
310
378
|
.build()
|
|
311
379
|
)
|
|
312
380
|
|
|
313
|
-
slack.send_message(channel="#data-pipeline", blocks=blocks)
|
|
381
|
+
slack.send_message(channel="#data-pipeline", text="Pipeline summary", blocks=blocks)
|
|
314
382
|
```
|
|
315
383
|
|
|
316
384
|
## File Uploads
|
|
317
385
|
|
|
386
|
+
The client uploads content, not paths: read the file yourself and pass the bytes
|
|
387
|
+
or text.
|
|
388
|
+
|
|
318
389
|
```python
|
|
319
390
|
from nui_shared_utils import SlackClient
|
|
320
391
|
|
|
321
392
|
slack = SlackClient()
|
|
322
393
|
|
|
323
|
-
# Upload file
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
file_path="/tmp/report.csv",
|
|
327
|
-
title="Daily Sales Report",
|
|
328
|
-
initial_comment="Today's sales data attached"
|
|
329
|
-
)
|
|
330
|
-
|
|
331
|
-
# Upload file content directly
|
|
394
|
+
# Upload file content. Uploads need a channel ID, not a name: the SDK passes
|
|
395
|
+
# this straight to files.completeUploadExternal as `channel_id`, so "#reports"
|
|
396
|
+
# uploads the bytes and then fails to share them, returning False.
|
|
332
397
|
csv_content = "name,value\nItem 1,100\nItem 2,200"
|
|
333
|
-
slack.
|
|
334
|
-
|
|
335
|
-
content=csv_content
|
|
398
|
+
slack.send_file(
|
|
399
|
+
channel="C1234567890",
|
|
400
|
+
content=csv_content,
|
|
336
401
|
filename="sales_summary.csv",
|
|
337
402
|
title="Sales Summary"
|
|
338
403
|
)
|
|
404
|
+
|
|
405
|
+
# Upload from a path by reading it first
|
|
406
|
+
with open("/tmp/report.csv", "rb") as fh:
|
|
407
|
+
slack.send_file(
|
|
408
|
+
channel="C1234567890",
|
|
409
|
+
content=fh.read(),
|
|
410
|
+
filename="report.csv",
|
|
411
|
+
title="Daily Sales Report"
|
|
412
|
+
)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Pass `thread_ts` to upload into a thread, and use `post_file` when you need the
|
|
416
|
+
upload's response back or want a failure to raise rather than return `False`:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
sent = slack.post_message(channel="#reports", text="Tender results attached")
|
|
420
|
+
response = slack.post_file(
|
|
421
|
+
channel=sent.channel,
|
|
422
|
+
content=pdf_bytes,
|
|
423
|
+
filename="tender.pdf",
|
|
424
|
+
thread_ts=sent.ts
|
|
425
|
+
)
|
|
426
|
+
file_id = response["files"][0]["id"]
|
|
339
427
|
```
|
|
340
428
|
|
|
341
429
|
## Channel Management
|
|
342
430
|
|
|
431
|
+
`SlackClient` sends; it does not enumerate or look up channels. Use the Slack SDK
|
|
432
|
+
directly for that, with the same bot token:
|
|
433
|
+
|
|
343
434
|
```python
|
|
344
|
-
|
|
435
|
+
from slack_sdk import WebClient
|
|
345
436
|
|
|
346
|
-
|
|
347
|
-
channels = slack.list_channels()
|
|
348
|
-
for channel in channels:
|
|
349
|
-
print(f"{channel['name']}: {channel['id']}")
|
|
437
|
+
web = WebClient(token=bot_token)
|
|
350
438
|
|
|
351
|
-
#
|
|
352
|
-
|
|
439
|
+
# List channels
|
|
440
|
+
for channel in web.conversations_list()["channels"]:
|
|
441
|
+
print(f"{channel['name']}: {channel['id']}")
|
|
353
442
|
|
|
354
|
-
#
|
|
355
|
-
info =
|
|
443
|
+
# Channel info by id
|
|
444
|
+
info = web.conversations_info(channel="C1234567890")["channel"]
|
|
356
445
|
```
|
|
357
446
|
|
|
447
|
+
To create channels from a YAML definition, see the `slack-channel-setup` CLI
|
|
448
|
+
shipped with this package.
|
|
449
|
+
|
|
358
450
|
## Error Handling
|
|
359
451
|
|
|
360
452
|
### Retry Logic
|
|
@@ -362,11 +454,13 @@ info = slack.get_channel_info("C1234567890")
|
|
|
362
454
|
```python
|
|
363
455
|
from nui_shared_utils import with_retry, SlackClient
|
|
364
456
|
|
|
365
|
-
@with_retry(max_attempts=3,
|
|
457
|
+
@with_retry(max_attempts=3, exponential_base=2)
|
|
366
458
|
def send_critical_alert(message: str):
|
|
367
459
|
"""Send message with automatic retry on failure."""
|
|
368
460
|
slack = SlackClient()
|
|
369
|
-
|
|
461
|
+
# post_message, not send_message: `with_retry` retries a raised exception,
|
|
462
|
+
# and send_message swallows its errors into a False the retry never sees.
|
|
463
|
+
slack.post_message(channel="#alerts", text=message)
|
|
370
464
|
```
|
|
371
465
|
|
|
372
466
|
### Graceful Degradation
|
|
@@ -376,11 +470,22 @@ from nui_shared_utils import SlackClient
|
|
|
376
470
|
|
|
377
471
|
def notify_with_fallback(message: str):
|
|
378
472
|
"""Try Slack notification with fallback to logging."""
|
|
473
|
+
slack = SlackClient()
|
|
474
|
+
if not slack.send_message(channel="#notifications", text=message):
|
|
475
|
+
# Fallback to CloudWatch logs. send_message reports failure by
|
|
476
|
+
# returning False, so testing the return is what catches it; wrapping
|
|
477
|
+
# this call in try/except would give you an except block that can
|
|
478
|
+
# never run.
|
|
479
|
+
print(f"Slack notification failed, message content: {message}")
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Use `post_message` instead when you want the reason rather than just the fact:
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
def notify_with_fallback(message: str):
|
|
379
486
|
try:
|
|
380
|
-
|
|
381
|
-
slack.send_message(channel="#notifications", text=message)
|
|
487
|
+
SlackClient().post_message(channel="#notifications", text=message)
|
|
382
488
|
except Exception as e:
|
|
383
|
-
# Fallback to CloudWatch logs
|
|
384
489
|
print(f"Slack notification failed: {e}")
|
|
385
490
|
print(f"Message content: {message}")
|
|
386
491
|
```
|
|
@@ -415,8 +520,8 @@ Slack has rate limits:
|
|
|
415
520
|
|
|
416
521
|
```python
|
|
417
522
|
# Good: Use threading for related messages
|
|
418
|
-
|
|
419
|
-
slack.
|
|
523
|
+
sent = slack.post_message(channel="#support", text="Main message")
|
|
524
|
+
slack.send_thread_reply(channel=sent.channel, thread_ts=sent.ts, text="Details")
|
|
420
525
|
|
|
421
526
|
# Avoid: Flooding channel with sequential messages
|
|
422
527
|
for item in items: # Could hit rate limit
|
|
@@ -87,6 +87,7 @@ _LAZY_EXPORTS = {
|
|
|
87
87
|
"get_lambda_environment_info": ("lambda_helpers", "get_lambda_environment_info"),
|
|
88
88
|
# Optional: Slack client (slack-sdk)
|
|
89
89
|
"SlackClient": ("slack_client", "SlackClient"),
|
|
90
|
+
"SentMessage": ("slack_client", "SentMessage"),
|
|
90
91
|
# Optional: Elasticsearch client + query builder
|
|
91
92
|
"ElasticsearchClient": ("es_client", "ElasticsearchClient"),
|
|
92
93
|
"ESQueryBuilder": ("es_query_builder", "ESQueryBuilder"),
|
|
@@ -265,7 +266,7 @@ if TYPE_CHECKING:
|
|
|
265
266
|
extract_cloudwatch_logs_from_kinesis,
|
|
266
267
|
)
|
|
267
268
|
from .lambda_helpers import get_lambda_environment_info
|
|
268
|
-
from .slack_client import SlackClient
|
|
269
|
+
from .slack_client import SentMessage, SlackClient
|
|
269
270
|
from .es_client import ElasticsearchClient
|
|
270
271
|
from .es_query_builder import (
|
|
271
272
|
ESQueryBuilder,
|
{nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/powertools_helpers.py
RENAMED
|
@@ -145,6 +145,17 @@ def get_powertools_logger(
|
|
|
145
145
|
return powertools_logger
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def _is_sqs_event(event: Any) -> bool:
|
|
149
|
+
"""True when ``event`` is a batch delivered by an SQS event source mapping."""
|
|
150
|
+
if not isinstance(event, dict):
|
|
151
|
+
return False
|
|
152
|
+
records = event.get("Records")
|
|
153
|
+
if not isinstance(records, list) or not records:
|
|
154
|
+
return False
|
|
155
|
+
first = records[0]
|
|
156
|
+
return isinstance(first, dict) and first.get("eventSource") == "aws:sqs"
|
|
157
|
+
|
|
158
|
+
|
|
148
159
|
def powertools_handler(
|
|
149
160
|
service_name: str,
|
|
150
161
|
metrics_namespace: Optional[str] = None,
|
|
@@ -199,8 +210,17 @@ def powertools_handler(
|
|
|
199
210
|
... return {"statusCode": 200}
|
|
200
211
|
|
|
201
212
|
Note:
|
|
202
|
-
The
|
|
203
|
-
|
|
213
|
+
The handler's return value passes through unchanged: a ``statusCode`` dict
|
|
214
|
+
for API Gateway, ``{"batchItemFailures": [...]}`` for an SQS mapping with
|
|
215
|
+
``ReportBatchItemFailures``.
|
|
216
|
+
|
|
217
|
+
On exception, returns ``{"statusCode": 500, "body": "Internal Server Error"}``,
|
|
218
|
+
except for SQS events, where the exception is re-raised after logging and
|
|
219
|
+
alerting. A returned value tells the SQS mapping the batch succeeded (with
|
|
220
|
+
``ReportBatchItemFailures``, a response without ``batchItemFailures`` means
|
|
221
|
+
every record did), so the 500 dict would delete the batch. Re-raising leaves
|
|
222
|
+
it on the queue for redelivery, and in the DLQ after ``maxReceiveCount``
|
|
223
|
+
when the queue has a redrive policy.
|
|
204
224
|
"""
|
|
205
225
|
|
|
206
226
|
def decorator(func: Callable) -> Callable:
|
|
@@ -231,6 +251,9 @@ def powertools_handler(
|
|
|
231
251
|
if slack_client:
|
|
232
252
|
slack_client.set_handler_context(context)
|
|
233
253
|
|
|
254
|
+
# Classify before the handler runs: it may consume or mutate event["Records"].
|
|
255
|
+
is_sqs = _is_sqs_event(event)
|
|
256
|
+
|
|
234
257
|
try:
|
|
235
258
|
# Apply logger context injection
|
|
236
259
|
# Note: inject_lambda_context is added dynamically to logging.Logger (line 95)
|
|
@@ -274,6 +297,10 @@ def powertools_handler(
|
|
|
274
297
|
except Exception as slack_error:
|
|
275
298
|
logger.warning("Failed to send Slack alert: %s", slack_error)
|
|
276
299
|
|
|
300
|
+
# Re-raise for SQS so the batch stays on the queue; see the Note above.
|
|
301
|
+
if is_sqs:
|
|
302
|
+
raise
|
|
303
|
+
|
|
277
304
|
# Return proper Lambda error response
|
|
278
305
|
return {
|
|
279
306
|
"statusCode": 500,
|