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.
Files changed (101) hide show
  1. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/PKG-INFO +1 -1
  2. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/powertools-integration.md +16 -0
  3. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/slack-integration.md +150 -45
  4. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/__init__.py +2 -1
  5. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/powertools_helpers.py +29 -2
  6. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_client.py +471 -121
  7. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_powertools_helpers.py +147 -0
  8. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_slack_client.py +707 -1
  9. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.editorconfig +0 -0
  10. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/ci.yml +0 -0
  11. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/publish.yml +0 -0
  12. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.github/workflows/test.yml +0 -0
  13. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/.markdownlint-cli2.yaml +0 -0
  14. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/AGENTS.md +0 -0
  15. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/CLAUDE.md +0 -0
  16. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/CONTRIBUTING.md +0 -0
  17. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/LICENSE +0 -0
  18. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/MANIFEST.in +0 -0
  19. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/README.md +0 -0
  20. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/README.md +0 -0
  21. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-001-structured-logging-field-vocabulary.md +0 -0
  22. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-002-keep-shared-utilities-generic.md +0 -0
  23. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-003-lazy-load-integrations.md +0 -0
  24. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-004-credential-resolution-precedence.md +0 -0
  25. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-005-explicit-secrets-manager-region.md +0 -0
  26. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-006-snowflake-sql-api-client.md +0 -0
  27. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-007-elasticsearch-client-version-range.md +0 -0
  28. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/ADR-008-stdlib-zoneinfo-over-pytz.md +0 -0
  29. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/decisions/README.md +0 -0
  30. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/development/testing.md +0 -0
  31. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/configuration.md +0 -0
  32. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/installation.md +0 -0
  33. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/getting-started/quickstart.md +0 -0
  34. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/cli-tools.md +0 -0
  35. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/elasticsearch-integration.md +0 -0
  36. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/jwt-authentication.md +0 -0
  37. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/lambda-utilities.md +0 -0
  38. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/llm-integration.md +0 -0
  39. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/log-processing.md +0 -0
  40. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/shared-types.md +0 -0
  41. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/snowflake-integration.md +0 -0
  42. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/docs/guides/structured-logging-contract.md +0 -0
  43. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/mypy.ini +0 -0
  44. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_python_shared_utils.egg-info/SOURCES.txt +0 -0
  45. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/base_client.py +0 -0
  46. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/cli.py +0 -0
  47. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/cloudwatch_metrics.py +0 -0
  48. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/config.py +0 -0
  49. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/db_client.py +0 -0
  50. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/error_handler.py +0 -0
  51. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/es_client.py +0 -0
  52. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/es_query_builder.py +0 -0
  53. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/jwt_auth.py +0 -0
  54. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/lambda_helpers.py +0 -0
  55. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/llm.py +0 -0
  56. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/log_processors.py +0 -0
  57. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/__init__.py +0 -0
  58. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/binding.py +0 -0
  59. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/fields.py +0 -0
  60. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/logging/redaction.py +0 -0
  61. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/secrets_helper.py +0 -0
  62. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_formatter.py +0 -0
  63. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/__init__.py +0 -0
  64. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/channel_creator.py +0 -0
  65. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/channel_definitions.py +0 -0
  66. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/slack_setup/setup_helpers.py +0 -0
  67. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/snowflake_client.py +0 -0
  68. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/timezone.py +0 -0
  69. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/nui_shared_utils/utils.py +0 -0
  70. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/pyproject.toml +0 -0
  71. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/pytest.ini +0 -0
  72. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/README.md +0 -0
  73. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/pyproject.toml +0 -0
  74. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/redirect/setup.py +0 -0
  75. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/requirements-test.txt +0 -0
  76. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/scripts/bench_imports.py +0 -0
  77. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/setup.cfg +0 -0
  78. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/setup.py +0 -0
  79. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/__init__.py +0 -0
  80. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_aws_utils.py +0 -0
  81. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_base_client.py +0 -0
  82. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_cloudwatch_metrics.py +0 -0
  83. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_config.py +0 -0
  84. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_db_client.py +0 -0
  85. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_error_handler.py +0 -0
  86. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_client.py +0 -0
  87. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_client_compat.py +0 -0
  88. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_es_query_builder.py +0 -0
  89. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_jwt_auth.py +0 -0
  90. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_lambda_helpers.py +0 -0
  91. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_lazy_imports.py +0 -0
  92. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_llm.py +0 -0
  93. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_log_processors.py +0 -0
  94. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_logging_binding.py +0 -0
  95. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_logging_fields.py +0 -0
  96. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_redaction.py +0 -0
  97. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_secrets_helper.py +0 -0
  98. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_slack_formatter.py +0 -0
  99. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_snowflake_client.py +0 -0
  100. {nui_python_shared_utils-2.1 → nui_python_shared_utils-2.3}/tests/test_timezone.py +0 -0
  101. {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.1
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
@@ -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
@@ -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
- response = slack.send_message(
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
- thread_ts = response['ts']
108
- slack.send_message(
109
- channel="#support",
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 (requires looking up user first)
125
- user = slack.get_user_by_email("user@example.com")
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['id'],
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
- response = slack.send_message(
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.send_message(
278
- channel="#alerts-critical",
279
- text=f"```{error_details}```",
280
- thread_ts=response['ts']
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 from path
324
- slack.upload_file(
325
- channels="#reports",
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.upload_file(
334
- channels="#reports",
335
- content=csv_content.encode(),
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
- slack = SlackClient()
435
+ from slack_sdk import WebClient
345
436
 
346
- # List all channels
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
- # Find channel by name
352
- channel_id = slack.get_channel_id("general")
439
+ # List channels
440
+ for channel in web.conversations_list()["channels"]:
441
+ print(f"{channel['name']}: {channel['id']}")
353
442
 
354
- # Get channel info
355
- info = slack.get_channel_info("C1234567890")
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, backoff_factor=2)
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
- slack.send_message(channel="#alerts", text=message)
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
- slack = SlackClient()
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
- response = slack.send_message(channel="#support", text="Main message")
419
- slack.send_message(channel="#support", text="Details", thread_ts=response['ts'])
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,
@@ -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 decorated handler must return a dict with statusCode and optional body.
203
- On exception, returns: {"statusCode": 500, "body": "Internal Server Error"}
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,