import logging from datetime import datetime from typing import Any, Dict, List, Optional from humanize import precisedelta as humanize_delta from slack_sdk.web import SlackResponse, WebClient from smelog.factory import BoundLogger from config import SLACK_API_TOKEN, SLACK_CHANNEL_NAME from .failsafe import failsafe from .key import Key logging.getLogger("slack_sdk").setLevel(logging.WARNING) __all__ = ["SlackReporter"] class SlackReporter: __token: str = SLACK_API_TOKEN _channel_name: str = SLACK_CHANNEL_NAME _timeout: int = 5 _step_header_icons_map: Dict[str, str] = { "started": ":arrow_forward:", "done": ":white_check_mark:", "failed": ":octagonal_sign:", } def __init__(self, logger: BoundLogger, key: Key): self.logger = logger self.key = key self._client: Optional[WebClient] = None self._step_data: Dict[str, Any] = {} self._channel_id: Optional[str] = None if self.__token is None: self.logger.warning("Slack api token is not defined. Slack integration will be disabled") return self._client = WebClient(token=self.__token, timeout=self._timeout) def _add_context(self, blocks: List[Dict[str, Any]]): blocks.append( { "type": "context", "elements": [ { "type": "plain_text", "text": f"Key: {self.key}", } ], } ) def _get_step_icon(self, state: str) -> str: return self._step_header_icons_map[state] def _get_channel(self) -> str: if self._channel_id is not None: return self._channel_id return self._channel_name @staticmethod def _format_perf_report(raw_report: Dict[str, Any], detailed: bool = True) -> str: report = "Performance report:\n" if not raw_report: report += "Nothing to report" if "total" in raw_report: report += f"Total time: {humanize_delta(raw_report.pop('total')['time'])}\n" if detailed and raw_report: for k, v in raw_report.items(): report += ( f"{k}: happens {v['events']} times and took {humanize_delta(v['time'])}. " f"({humanize_delta(v['avg'])} in average)\n" ) return report @failsafe def _update_blocks(self, ts: str, blocks: List[Dict[str, Any]], text: str) -> Optional[SlackResponse]: if self._client is None: return None self._add_context(blocks) return self._client.chat_update(channel=self._get_channel(), ts=ts, blocks=blocks, text=text) @failsafe def _post_blocks(self, blocks: List[Dict[str, Any]], text: str) -> Optional[SlackResponse]: if self._client is None: return None self._add_context(blocks) response = self._client.chat_postMessage(channel=self._get_channel(), blocks=blocks, text=text) if self._channel_id is None: self._channel_id = response["channel"] return response @failsafe def notify_step_started(self, step_name: str): timestamp = datetime.utcnow().isoformat() message = [ { "type": "section", "text": { "type": "mrkdwn", "text": f"{self._get_step_icon('started')} *{step_name}*\n" f"{timestamp}: started", }, } ] response = self._post_blocks(message, f"{step_name} started") self._step_data[step_name] = {"ts": response["ts"], "started": timestamp} @failsafe def notify_step_done(self, step_name: str, perf_report: Dict[str, Any]): text = f"{self._get_step_icon('done')} *{step_name}*\n" if step_name in self._step_data: text += f"{self._step_data[step_name]['started']}: started\n" text += f"{datetime.utcnow().isoformat()}: done" message = [ {"type": "section", "text": {"type": "mrkdwn", "text": text}}, {"type": "divider"}, {"type": "section", "text": {"type": "mrkdwn", "text": self._format_perf_report(perf_report)}}, ] if step_name not in self._step_data: self._post_blocks(message, f"{step_name} done") else: self._update_blocks(self._step_data[step_name]["ts"], message, f"{step_name} done") @failsafe def notify_step_failed(self, step_name: str): text = f"{self._get_step_icon('failed')} *{step_name}*\n" if step_name in self._step_data: text += f"{self._step_data[step_name]['started']}: started\n" text += f"{datetime.utcnow().isoformat()}: failed @channel" message = [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] if step_name not in self._step_data: self._post_blocks(message, f"{step_name} done") else: self._update_blocks(self._step_data[step_name]["ts"], message, f"{step_name} failed") def post_message(self, message: str): return self._post_blocks( [ { "type": "section", "text": { "type": "plain_text", "text": message, }, } ], message, ) def post_header(self, message: str): return self._post_blocks( [{"type": "header", "text": {"type": "plain_text", "text": message, "emoji": True}}], message )