nui-python-shared-utils 2.1__py3-none-any.whl → 2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,5 +1,5 @@
1
- nui_python_shared_utils-2.1.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
2
- nui_shared_utils/__init__.py,sha256=vFS5oaw7_iHgHnLRifDYYyb7au6G2nYJ6ztqMs-hrQs,13289
1
+ nui_python_shared_utils-2.3.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
2
+ nui_shared_utils/__init__.py,sha256=wbm10x7THNbrCBGClWiWSVkTNxcBKcHZ-HjYpRQBFrk,13354
3
3
  nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
4
4
  nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
5
5
  nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
@@ -12,9 +12,9 @@ nui_shared_utils/jwt_auth.py,sha256=2Ag1zZKxd2R8QBz3aQA4h8OtKHQvUVKrjUp6lXpJxJU,
12
12
  nui_shared_utils/lambda_helpers.py,sha256=psHVotpmOfnmyQCoOt4MSIEj7VwWcy6gZM3vYSZwjOk,3041
13
13
  nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
14
14
  nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
15
- nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
15
+ nui_shared_utils/powertools_helpers.py,sha256=R4awhoo0w0niL662DMZvNa5sDjjP8Vh6m0pSpnOD1aU,12286
16
16
  nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
17
- nui_shared_utils/slack_client.py,sha256=wXE0D4T4FWt1giz9Xmp71RustpaJYeLAZo6JxulDVko,25550
17
+ nui_shared_utils/slack_client.py,sha256=jvkLeBMA8WHOqdRzCEpG42FfBlMeWue8bZ1g1ZxF-UI,40304
18
18
  nui_shared_utils/slack_formatter.py,sha256=QT9VXApE0q09n_PmEVvaLCSfLW24vmowlCXF3Dzimfk,10850
19
19
  nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
20
20
  nui_shared_utils/timezone.py,sha256=gTiSK5NUG0Z8UY_AsB4ktIzLrZ0wwTXtdTUH-Npdvik,3837
@@ -27,8 +27,8 @@ nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e5
27
27
  nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
28
28
  nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
29
29
  nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
30
- nui_python_shared_utils-2.1.dist-info/METADATA,sha256=fWK7ffhd2g2e4rKPJeAhStSKUECYAxImYWsvGZyTbvM,22688
31
- nui_python_shared_utils-2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
- nui_python_shared_utils-2.1.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
33
- nui_python_shared_utils-2.1.dist-info/top_level.txt,sha256=YMRu5cnn3hxHoYq9wNvaI9atVHnTNVdswO5_cQSfnJI,17
34
- nui_python_shared_utils-2.1.dist-info/RECORD,,
30
+ nui_python_shared_utils-2.3.dist-info/METADATA,sha256=Ma366Cqx3pp_PW7xaSA-n1UmeniBgjvShEwb96vdOgc,22688
31
+ nui_python_shared_utils-2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
+ nui_python_shared_utils-2.3.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
33
+ nui_python_shared_utils-2.3.dist-info/top_level.txt,sha256=YMRu5cnn3hxHoYq9wNvaI9atVHnTNVdswO5_cQSfnJI,17
34
+ nui_python_shared_utils-2.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,
@@ -4,10 +4,12 @@ Refactored Slack client using BaseClient for DRY code patterns.
4
4
 
5
5
  import os
6
6
  import logging
7
- from typing import Any, List, Dict, Optional
7
+ from dataclasses import dataclass
8
+ from typing import Any, List, Dict, Optional, Union
8
9
  from pathlib import Path
9
10
  from slack_sdk import WebClient
10
11
  from slack_sdk.errors import SlackApiError
12
+ from slack_sdk.web.slack_response import SlackResponse
11
13
  from datetime import datetime
12
14
 
13
15
  try:
@@ -30,9 +32,60 @@ DEFAULT_ACCOUNT_NAMES = {
30
32
  }
31
33
 
32
34
 
35
+ @dataclass(frozen=True)
36
+ class SentMessage:
37
+ """Slack's answer to a ``chat.postMessage`` call.
38
+
39
+ ``ts`` is the handle Slack addresses a message by. Keep it to reply under
40
+ the message (:meth:`SlackClient.send_thread_reply`), edit it in place
41
+ (:meth:`SlackClient.update_message`) or annotate it
42
+ (:meth:`SlackClient.add_reaction`). Without it those three are unreachable,
43
+ which is why :meth:`SlackClient.post_message` exists alongside the
44
+ bool-returning :meth:`SlackClient.send_message`.
45
+
46
+ Truthiness follows ``ok``. Note that a SentMessage returned by
47
+ :meth:`SlackClient.post_message` against a real client is always ``ok``,
48
+ because ``slack_sdk`` raises on a rejection rather than returning one, so
49
+ ``if sent:`` is not a failure guard there. ``ok`` earns its place for a
50
+ client that does not validate, and for :meth:`SlackClient.send_message`,
51
+ which converts this type into its bool.
52
+
53
+ Attributes:
54
+ ok: Whether Slack accepted the message.
55
+ ts: Message timestamp, or None when Slack did not return one.
56
+ channel: Channel Slack recorded the message against. Worth keeping
57
+ rather than reusing the channel you passed: posting to a user ID
58
+ or channel name resolves to a different id than the one you sent.
59
+ response: The underlying ``slack_sdk`` response, for fields this type
60
+ does not name.
61
+ """
62
+
63
+ ok: bool
64
+ ts: Optional[str] = None
65
+ channel: Optional[str] = None
66
+ response: Any = None
67
+
68
+ def __bool__(self) -> bool:
69
+ return self.ok
70
+
71
+
33
72
  class SlackClient(BaseClient, ServiceHealthMixin):
34
73
  """
35
74
  Refactored Slack client with standardized patterns and reduced duplication.
75
+
76
+ Two families of write method, differing only in what they give back:
77
+
78
+ - ``send_message`` / ``send_thread_reply`` / ``update_message`` /
79
+ ``send_file`` / ``add_reaction`` return a bool and swallow every error.
80
+ - ``post_message`` / ``post_thread_reply`` / ``post_update`` / ``post_file``
81
+ return Slack's answer and **raise** on every failure, rejections included.
82
+ The shared ``post_`` prefix marks that return contract, not the Slack call:
83
+ ``post_update`` edits a message in place rather than posting a new one.
84
+
85
+ Reach for a ``post_*`` method when you need the message's ``ts`` (the only
86
+ handle an edit, reaction or thread reply accepts) or the error detail behind
87
+ a failure. Reach for the others when a failed notification should not take
88
+ the caller down with it. Each method's docstring names its counterpart.
36
89
  """
37
90
 
38
91
  def __init__(
@@ -380,6 +433,145 @@ class SlackClient(BaseClient, ServiceHealthMixin):
380
433
  }]
381
434
  }]
382
435
 
436
+ def _blocks_with_header(
437
+ self,
438
+ blocks: Optional[List[Dict]],
439
+ include_lambda_header: bool,
440
+ event_type: Optional[str],
441
+ ) -> Optional[List[Dict]]:
442
+ """Prepend the standard context header to ``blocks`` when requested.
443
+
444
+ Shared by send_message and post_message so both head a message the same
445
+ way. Consumers used to rebuild this by calling the private header
446
+ builders directly; call post_message instead.
447
+ """
448
+ if not include_lambda_header:
449
+ return blocks
450
+
451
+ if self._lambda_context["function_name"] != "Unknown":
452
+ header_blocks = self._create_lambda_header_block(event_type=event_type)
453
+ else:
454
+ header_blocks = self._create_local_header_block(event_type=event_type)
455
+
456
+ return header_blocks + (blocks or [])
457
+
458
+ def _chat_post(
459
+ self,
460
+ channel: str,
461
+ text: str,
462
+ blocks: Optional[List[Dict]],
463
+ attachments: Optional[List[Dict]],
464
+ include_lambda_header: bool,
465
+ event_type: Optional[str],
466
+ unfurl_links: Optional[bool],
467
+ unfurl_media: Optional[bool],
468
+ ) -> SentMessage:
469
+ """Build the payload, post it, log the outcome, return Slack's answer."""
470
+ kwargs: Dict[str, Any] = dict(
471
+ channel=channel,
472
+ text=text,
473
+ blocks=self._blocks_with_header(blocks, include_lambda_header, event_type),
474
+ attachments=attachments,
475
+ )
476
+ # Forward unfurl controls only when explicitly set, so existing callers
477
+ # keep Slack's default behavior (and their call assertions stay unchanged).
478
+ if unfurl_links is not None:
479
+ kwargs["unfurl_links"] = unfurl_links
480
+ if unfurl_media is not None:
481
+ kwargs["unfurl_media"] = unfurl_media
482
+
483
+ response = self._service_client.chat_postMessage(**kwargs)
484
+
485
+ sent = SentMessage(
486
+ ok=bool(response.get("ok", False)),
487
+ ts=response.get("ts"),
488
+ channel=response.get("channel"),
489
+ response=response,
490
+ )
491
+
492
+ if sent.ok:
493
+ log.info("Slack message sent successfully", extra={"channel": channel, "ts": sent.ts})
494
+ else:
495
+ log.error("Slack API returned error", extra={"error": response.get("error", "Unknown error")})
496
+ return sent
497
+
498
+ @handle_client_errors(reraise=True)
499
+ def post_message(
500
+ self,
501
+ channel: str,
502
+ text: str,
503
+ blocks: Optional[List[Dict]] = None,
504
+ attachments: Optional[List[Dict]] = None,
505
+ include_lambda_header: bool = True,
506
+ event_type: Optional[str] = None,
507
+ unfurl_links: Optional[bool] = None,
508
+ unfurl_media: Optional[bool] = None,
509
+ ) -> SentMessage:
510
+ """
511
+ Send a message and return Slack's response, including the ``ts``. Raises.
512
+
513
+ **Like every ``post_*`` method here, it raises rather than swallowing.**
514
+ The ``send_*`` / ``update_message`` family logs and returns a default
515
+ instead; this one lets the exception reach you, because a caller holding
516
+ a ``ts`` generally needs the rejection code, HTTP status and retry headers
517
+ that a bare False cannot express. Wrap the call accordingly, or use
518
+ send_message if you want the swallowing behaviour.
519
+
520
+ **It returns on success and raises on every failure**, Slack's own
521
+ rejections included: ``slack_sdk`` validates each response before handing
522
+ it back, so ``{"ok": false, "error": ...}`` arrives as a ``SlackApiError``
523
+ carrying that detail on ``err.response``, never as a returned value. So do
524
+ not write ``if sent:`` as a failure guard, because it never runs; catch
525
+ instead. (``SentMessage.ok`` stays for defensive handling of a client that
526
+ does not validate, such as a test double returning a plain dict; against a
527
+ real client a returned SentMessage is always ``ok``.)
528
+
529
+ Otherwise it is send_message: same arguments, same standard header. The
530
+ difference is what comes back. send_message answers True/False and
531
+ discards the ``ts``, which is the only handle send_thread_reply,
532
+ update_message and add_reaction accept, so a caller that may later thread
533
+ under, edit or react to its own message wants this one.
534
+
535
+ Args:
536
+ channel: Channel ID
537
+ text: Fallback text
538
+ blocks: Rich formatted blocks
539
+ attachments: Legacy attachment objects (supports color sidebars)
540
+ include_lambda_header: Whether to include context header
541
+ event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
542
+ unfurl_links: Override Slack link unfurling. None leaves Slack's default;
543
+ False suppresses link preview cards (links stay clickable)
544
+ unfurl_media: Override Slack media unfurling. None leaves Slack's default;
545
+ False suppresses media preview cards
546
+
547
+ Returns:
548
+ SentMessage carrying ok, ts, channel and the raw response.
549
+
550
+ Raises:
551
+ slack_sdk.errors.SlackApiError: Slack rejected the call, at the
552
+ transport layer or by answering ``ok: false``. ``err.response``
553
+ carries the error code, status and any retry headers. Any other
554
+ exception raised by the underlying client propagates too.
555
+
556
+ Example:
557
+ sent = client.post_message("C123", "Build started")
558
+ client.send_thread_reply(sent.channel, sent.ts, "Build finished")
559
+ """
560
+ return self._execute_with_error_handling(
561
+ "post_message",
562
+ lambda: self._chat_post(
563
+ channel=channel,
564
+ text=text,
565
+ blocks=blocks,
566
+ attachments=attachments,
567
+ include_lambda_header=include_lambda_header,
568
+ event_type=event_type,
569
+ unfurl_links=unfurl_links,
570
+ unfurl_media=unfurl_media,
571
+ ),
572
+ channel=channel,
573
+ )
574
+
383
575
  @handle_client_errors(default_return=False)
384
576
  def send_message(
385
577
  self,
@@ -409,103 +601,181 @@ class SlackClient(BaseClient, ServiceHealthMixin):
409
601
 
410
602
  Returns:
411
603
  True if successful, False otherwise
412
- """
413
- def _send_operation():
414
- # Add context header if requested
415
- if include_lambda_header:
416
- if self._lambda_context["function_name"] != "Unknown":
417
- header_blocks = self._create_lambda_header_block(event_type=event_type)
418
- else:
419
- header_blocks = self._create_local_header_block(event_type=event_type)
420
604
 
421
- if blocks:
422
- blocks_with_header = header_blocks + blocks
423
- else:
424
- blocks_with_header = header_blocks
425
- else:
426
- blocks_with_header = blocks
427
-
428
- kwargs = dict(
605
+ See Also:
606
+ post_message: same arguments, returns Slack's response (with the
607
+ ``ts``) instead of a bool, and raises rather than swallowing.
608
+ """
609
+ sent = self._execute_with_error_handling(
610
+ "send_message",
611
+ lambda: self._chat_post(
429
612
  channel=channel,
430
613
  text=text,
431
- blocks=blocks_with_header,
614
+ blocks=blocks,
432
615
  attachments=attachments,
433
- )
434
- # Forward unfurl controls only when explicitly set, so existing callers
435
- # keep Slack's default behavior (and their call assertions stay unchanged).
436
- if unfurl_links is not None:
437
- kwargs["unfurl_links"] = unfurl_links
438
- if unfurl_media is not None:
439
- kwargs["unfurl_media"] = unfurl_media
440
- response = self._service_client.chat_postMessage(**kwargs)
441
-
442
- if response["ok"]:
443
- log.info(
444
- "Slack message sent successfully",
445
- extra={"channel": channel, "ts": response["ts"]}
446
- )
447
- return True
448
- else:
449
- log.error(
450
- "Slack API returned error",
451
- extra={"error": response.get("error", "Unknown error")}
452
- )
453
- return False
616
+ include_lambda_header=include_lambda_header,
617
+ event_type=event_type,
618
+ unfurl_links=unfurl_links,
619
+ unfurl_media=unfurl_media,
620
+ ),
621
+ channel=channel,
622
+ )
623
+ return bool(sent)
454
624
 
455
- return self._execute_with_error_handling(
456
- "send_message",
457
- _send_operation,
458
- channel=channel
625
+ def _upload_file(
626
+ self,
627
+ channel: str,
628
+ content: Union[str, bytes],
629
+ filename: str,
630
+ title: Optional[str],
631
+ thread_ts: Optional[str],
632
+ ) -> SlackResponse:
633
+ """Upload a file and log the outcome, returning Slack's raw response.
634
+
635
+ Shared by send_file and post_file. Unlike the message helpers this does
636
+ not build a SentMessage: an upload has no single message ``ts``, so one
637
+ would have to be invented.
638
+ """
639
+ kwargs: Dict[str, Any] = dict(
640
+ channel=channel,
641
+ content=content,
642
+ filename=filename,
643
+ title=title or filename,
459
644
  )
645
+ if thread_ts is not None:
646
+ kwargs["thread_ts"] = thread_ts
647
+
648
+ response = self._service_client.files_upload_v2(**kwargs)
649
+
650
+ if response.get("ok", False):
651
+ log.info("File uploaded successfully", extra={"channel": channel, "file_name": filename})
652
+ else:
653
+ log.error("Slack file upload failed", extra={"error": response.get("error", "Unknown error")})
654
+ return response
460
655
 
461
656
  @handle_client_errors(default_return=False)
462
657
  def send_file(
463
658
  self,
464
659
  channel: str,
465
- content: str,
660
+ content: Union[str, bytes],
466
661
  filename: str,
467
- title: Optional[str] = None
662
+ title: Optional[str] = None,
663
+ thread_ts: Optional[str] = None,
468
664
  ) -> bool:
469
665
  """
470
666
  Upload file to Slack channel.
471
-
667
+
472
668
  Args:
473
- channel: Channel ID
474
- content: File content
669
+ channel: Channel ID. A name will not do: the SDK forwards this as
670
+ ``channel_id`` to ``files.completeUploadExternal``, so a name
671
+ uploads the bytes and then fails to share them.
672
+ content: File content (str or bytes)
475
673
  filename: File name
476
674
  title: Optional title
477
-
675
+ thread_ts: Optional parent message timestamp, to upload into a thread
676
+
478
677
  Returns:
479
678
  True if successful, False otherwise
679
+
680
+ See Also:
681
+ post_file: same arguments, returns Slack's response and raises.
480
682
  """
481
- def _upload_operation():
482
- response = self._service_client.files_upload_v2(
483
- channel=channel,
484
- content=content,
485
- filename=filename,
486
- title=title or filename
487
- )
683
+ response = self._execute_with_error_handling(
684
+ "send_file",
685
+ lambda: self._upload_file(channel, content, filename, title, thread_ts),
686
+ channel=channel,
687
+ # not `filename`: logging reserves it on LogRecord, and passing it
688
+ # raises a KeyError that replaces whatever error we were reporting.
689
+ file_name=filename,
690
+ )
691
+ return bool(response.get("ok", False))
488
692
 
489
- if response["ok"]:
490
- log.info(
491
- "File uploaded successfully",
492
- extra={"channel": channel, "file_name": filename}
493
- )
494
- return True
495
- else:
496
- log.error(
497
- "Slack file upload failed",
498
- extra={"error": response.get("error", "Unknown error")}
499
- )
500
- return False
693
+ @handle_client_errors(reraise=True)
694
+ def post_file(
695
+ self,
696
+ channel: str,
697
+ content: Union[str, bytes],
698
+ filename: str,
699
+ title: Optional[str] = None,
700
+ thread_ts: Optional[str] = None,
701
+ ) -> SlackResponse:
702
+ """
703
+ Upload a file and return Slack's response. Raises.
704
+
705
+ Response-returning counterpart of send_file, following the same rule as
706
+ post_message: it returns on success and raises on every failure. The
707
+ return is ``files_upload_v2``'s completion response rather than a
708
+ SentMessage, because an upload has no single message ``ts``; read
709
+ ``response["files"]`` for what was created.
710
+
711
+ Args:
712
+ channel: Channel ID. A name will not do: the SDK forwards this as
713
+ ``channel_id`` to ``files.completeUploadExternal``, so a name
714
+ uploads the bytes and then fails to share them.
715
+ content: File content (str or bytes)
716
+ filename: File name
717
+ title: Optional title
718
+ thread_ts: Optional parent message timestamp, to upload into a thread
501
719
 
720
+ Returns:
721
+ ``files_upload_v2``'s completion response.
722
+
723
+ Raises:
724
+ slack_sdk.errors.SlackApiError: Slack rejected the upload. Any other
725
+ exception from the underlying client propagates too.
726
+ """
502
727
  return self._execute_with_error_handling(
503
- "send_file",
504
- _upload_operation,
728
+ "post_file",
729
+ lambda: self._upload_file(channel, content, filename, title, thread_ts),
505
730
  channel=channel,
506
- filename=filename
731
+ # not `filename`: logging reserves it on LogRecord, and passing it
732
+ # raises a KeyError that replaces whatever error we were reporting.
733
+ file_name=filename,
507
734
  )
508
735
 
736
+ def _chat_post_thread_reply(
737
+ self,
738
+ channel: str,
739
+ thread_ts: str,
740
+ text: str,
741
+ blocks: Optional[List[Dict]],
742
+ include_lambda_header: bool,
743
+ event_type: Optional[str],
744
+ ) -> SentMessage:
745
+ """Post a thread reply, log the outcome, return Slack's answer.
746
+
747
+ Shared by send_thread_reply and post_thread_reply. Note this heads a
748
+ reply only inside Lambda: unlike a top-level post it has never added the
749
+ local header, and that is preserved rather than unified.
750
+ """
751
+ blocks_with_header = blocks
752
+ if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
753
+ header_blocks = self._create_lambda_header_block(event_type=event_type)
754
+ blocks_with_header = header_blocks + (blocks or [])
755
+
756
+ response = self._service_client.chat_postMessage(
757
+ channel=channel,
758
+ thread_ts=thread_ts,
759
+ text=text,
760
+ blocks=blocks_with_header,
761
+ )
762
+
763
+ sent = SentMessage(
764
+ ok=bool(response.get("ok", False)),
765
+ ts=response.get("ts"),
766
+ channel=response.get("channel"),
767
+ response=response,
768
+ )
769
+
770
+ if sent.ok:
771
+ log.info(
772
+ "Thread reply sent successfully",
773
+ extra={"channel": channel, "thread_ts": thread_ts, "reply_ts": sent.ts},
774
+ )
775
+ else:
776
+ log.error("Failed to send thread reply", extra={"error": response.get("error", "Unknown error")})
777
+ return sent
778
+
509
779
  @handle_client_errors(default_return=False)
510
780
  def send_thread_reply(
511
781
  self,
@@ -529,48 +799,99 @@ class SlackClient(BaseClient, ServiceHealthMixin):
529
799
 
530
800
  Returns:
531
801
  True if successful, False otherwise
532
- """
533
- def _reply_operation():
534
- # Add header if requested (uncommon for thread replies)
535
- blocks_with_header = blocks
536
- if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
537
- header_blocks = self._create_lambda_header_block(event_type=event_type)
538
- if blocks:
539
- blocks_with_header = header_blocks + blocks
540
- else:
541
- blocks_with_header = header_blocks
542
802
 
543
- response = self._service_client.chat_postMessage(
803
+ See Also:
804
+ post_thread_reply: same arguments, returns Slack's response (with the
805
+ reply's own ``ts``) instead of a bool, and raises rather than swallowing.
806
+ """
807
+ sent = self._execute_with_error_handling(
808
+ "send_thread_reply",
809
+ lambda: self._chat_post_thread_reply(
544
810
  channel=channel,
545
811
  thread_ts=thread_ts,
546
812
  text=text,
547
- blocks=blocks_with_header
548
- )
813
+ blocks=blocks,
814
+ include_lambda_header=include_lambda_header,
815
+ event_type=event_type,
816
+ ),
817
+ channel=channel,
818
+ thread_ts=thread_ts,
819
+ )
820
+ return bool(sent)
549
821
 
550
- if response["ok"]:
551
- log.info(
552
- "Thread reply sent successfully",
553
- extra={
554
- "channel": channel,
555
- "thread_ts": thread_ts,
556
- "reply_ts": response["ts"]
557
- }
558
- )
559
- return True
560
- else:
561
- log.error(
562
- "Failed to send thread reply",
563
- extra={"error": response.get("error", "Unknown error")}
564
- )
565
- return False
822
+ @handle_client_errors(reraise=True)
823
+ def post_thread_reply(
824
+ self,
825
+ channel: str,
826
+ thread_ts: str,
827
+ text: str,
828
+ blocks: Optional[List[Dict]] = None,
829
+ include_lambda_header: bool = False,
830
+ event_type: Optional[str] = None,
831
+ ) -> SentMessage:
832
+ """
833
+ Send a thread reply and return Slack's response, including its own ``ts``.
834
+
835
+ Response-returning counterpart of send_thread_reply, following the same
836
+ rule as post_message: it returns on success and raises on every failure,
837
+ Slack's rejections included. A reply has a ``ts`` of its own, so this is
838
+ what you want when a later call edits or reacts to the reply itself.
839
+
840
+ Args:
841
+ channel: Channel ID
842
+ thread_ts: Parent message timestamp
843
+ text: Reply text
844
+ blocks: Optional blocks
845
+ include_lambda_header: Whether to include header
846
+ event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
847
+
848
+ Returns:
849
+ SentMessage for the reply, whose ``ts`` is the reply's, not the parent's.
566
850
 
851
+ Raises:
852
+ slack_sdk.errors.SlackApiError: Slack rejected the call. Any other
853
+ exception from the underlying client propagates too.
854
+ """
567
855
  return self._execute_with_error_handling(
568
- "send_thread_reply",
569
- _reply_operation,
856
+ "post_thread_reply",
857
+ lambda: self._chat_post_thread_reply(
858
+ channel=channel,
859
+ thread_ts=thread_ts,
860
+ text=text,
861
+ blocks=blocks,
862
+ include_lambda_header=include_lambda_header,
863
+ event_type=event_type,
864
+ ),
570
865
  channel=channel,
571
- thread_ts=thread_ts
866
+ thread_ts=thread_ts,
572
867
  )
573
868
 
869
+ def _chat_update(
870
+ self,
871
+ channel: str,
872
+ ts: str,
873
+ text: str,
874
+ blocks: Optional[List[Dict]],
875
+ ) -> SentMessage:
876
+ """Edit a message, log the outcome, return Slack's answer.
877
+
878
+ Shared by update_message and post_update.
879
+ """
880
+ response = self._service_client.chat_update(channel=channel, ts=ts, text=text, blocks=blocks)
881
+
882
+ sent = SentMessage(
883
+ ok=bool(response.get("ok", False)),
884
+ ts=response.get("ts"),
885
+ channel=response.get("channel"),
886
+ response=response,
887
+ )
888
+
889
+ if sent.ok:
890
+ log.info("Message updated successfully", extra={"channel": channel, "ts": ts})
891
+ else:
892
+ log.error("Failed to update message", extra={"error": response.get("error", "Unknown error")})
893
+ return sent
894
+
574
895
  @handle_client_errors(default_return=False)
575
896
  def update_message(
576
897
  self,
@@ -581,36 +902,65 @@ class SlackClient(BaseClient, ServiceHealthMixin):
581
902
  ) -> bool:
582
903
  """
583
904
  Update existing message.
584
-
905
+
585
906
  Args:
586
907
  channel: Channel ID
587
908
  ts: Message timestamp
588
909
  text: New text
589
910
  blocks: New blocks
590
-
911
+
591
912
  Returns:
592
913
  True if successful, False otherwise
914
+
915
+ See Also:
916
+ post_update: same arguments, returns Slack's response instead of a
917
+ bool, and raises rather than swallowing.
593
918
  """
594
- def _update_operation():
595
- response = self._service_client.chat_update(
596
- channel=channel,
597
- ts=ts,
598
- text=text,
599
- blocks=blocks
600
- )
919
+ sent = self._execute_with_error_handling(
920
+ "update_message",
921
+ lambda: self._chat_update(channel=channel, ts=ts, text=text, blocks=blocks),
922
+ channel=channel,
923
+ ts=ts,
924
+ )
925
+ return bool(sent)
601
926
 
602
- if response["ok"]:
603
- log.info("Message updated successfully", extra={"channel": channel, "ts": ts})
604
- return True
605
- else:
606
- log.error("Failed to update message", extra={"error": response.get("error", "Unknown error")})
607
- return False
927
+ @handle_client_errors(reraise=True)
928
+ def post_update(
929
+ self,
930
+ channel: str,
931
+ ts: str,
932
+ text: str,
933
+ blocks: Optional[List[Dict]] = None,
934
+ ) -> SentMessage:
935
+ """
936
+ Edit an existing message in place and return Slack's response. Raises.
937
+
938
+ This edits; it does not post anything new, despite the ``post_`` prefix
939
+ the response-returning family shares. It is the counterpart of
940
+ update_message and follows the same rule as post_message: it returns on
941
+ success and raises on every failure. Use it when an edit failing silently
942
+ would be wrong, or when you need the edited message's fields back.
943
+
944
+ Args:
945
+ channel: Channel ID. Pass the ``channel`` from the original post's
946
+ SentMessage, not the name you posted to: ``chat.update`` wants
947
+ the id.
948
+ ts: Message timestamp
949
+ text: New text
950
+ blocks: New blocks
951
+
952
+ Returns:
953
+ SentMessage for the edited message.
608
954
 
955
+ Raises:
956
+ slack_sdk.errors.SlackApiError: Slack rejected the edit. Any other
957
+ exception from the underlying client propagates too.
958
+ """
609
959
  return self._execute_with_error_handling(
610
- "update_message",
611
- _update_operation,
960
+ "post_update",
961
+ lambda: self._chat_update(channel=channel, ts=ts, text=text, blocks=blocks),
612
962
  channel=channel,
613
- ts=ts
963
+ ts=ts,
614
964
  )
615
965
 
616
966
  @handle_client_errors(default_return=False)