Skip to content

Commit 5e7da25

Browse files
authored
Merge branch 'master' into szaffarano/ssl-ca
2 parents 746ce80 + 912546c commit 5e7da25

124 files changed

Lines changed: 12670 additions & 389 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

elementary/config/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from elementary.monitor.alerts.grouping_type import GroupingType
1111
from elementary.utils.ordered_yaml import OrderedYaml
1212

13+
DEFAULT_ENV = "dev"
14+
1315

1416
class Config:
1517
_SLACK = "slack"
@@ -68,7 +70,7 @@ def __init__(
6870
azure_container_name: Optional[str] = None,
6971
report_url: Optional[str] = None,
7072
teams_webhook: Optional[str] = None,
71-
env: str = "dev",
73+
env: str = DEFAULT_ENV,
7274
run_dbt_deps_if_needed: Optional[bool] = None,
7375
project_name: Optional[str] = None,
7476
use_system_ca_files: bool = True,
@@ -252,6 +254,10 @@ def has_gcloud(self):
252254
def has_gcs(self):
253255
return self.gcs_bucket_name and self.has_gcloud
254256

257+
@property
258+
def specified_env(self) -> Optional[str]:
259+
return self.env if self.env != DEFAULT_ENV else None
260+
255261
def validate_monitor(self):
256262
provided_integrations = list(
257263
filter(

elementary/messages/block_builders.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ def SummaryLineBlock(
8787
return LineBlock(inlines=text_blocks)
8888

8989

90+
def NonPrimaryFactBlock(fact: Tuple[LineBlock, LineBlock]) -> FactBlock:
91+
title, value = fact
92+
return FactBlock(
93+
title=title,
94+
value=value,
95+
primary=False,
96+
)
97+
98+
99+
def PrimaryFactBlock(fact: Tuple[LineBlock, LineBlock]) -> FactBlock:
100+
title, value = fact
101+
return FactBlock(
102+
title=title,
103+
value=value,
104+
primary=True,
105+
)
106+
107+
90108
def FactsBlock(
91109
*,
92110
facts: Sequence[

elementary/messages/formats/adaptive_cards.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,9 @@
1616
TextBlock,
1717
TextStyle,
1818
)
19+
from elementary.messages.formats.html import ICON_TO_HTML
1920
from elementary.messages.message_body import Color, MessageBlock, MessageBody
2021

21-
ICON_TO_HTML = {
22-
Icon.RED_TRIANGLE: "🔺",
23-
Icon.X: "❌",
24-
Icon.WARNING: "⚠️",
25-
Icon.EXCLAMATION: "❗",
26-
Icon.CHECK: "✅",
27-
Icon.MAGNIFYING_GLASS: "🔎",
28-
Icon.HAMMER_AND_WRENCH: "🛠️",
29-
Icon.POLICE_LIGHT: "🚨",
30-
Icon.INFO: "ℹ️",
31-
Icon.EYE: "👁️",
32-
Icon.GEAR: "⚙️",
33-
Icon.BELL: "🔔",
34-
}
35-
3622
COLOR_TO_STYLE = {
3723
Color.RED: "Attention",
3824
Color.YELLOW: "Warning",
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
from typing import Any, Dict, List, Optional, Tuple
2+
3+
from slack_sdk.models import blocks as slack_blocks
4+
5+
from elementary.messages.blocks import (
6+
CodeBlock,
7+
DividerBlock,
8+
ExpandableBlock,
9+
FactBlock,
10+
FactListBlock,
11+
HeaderBlock,
12+
Icon,
13+
IconBlock,
14+
InlineBlock,
15+
LineBlock,
16+
LinesBlock,
17+
LinkBlock,
18+
TextBlock,
19+
TextStyle,
20+
)
21+
from elementary.messages.formats.html import ICON_TO_HTML
22+
from elementary.messages.message_body import Color, MessageBlock, MessageBody
23+
24+
COLOR_MAP = {
25+
Color.RED: "#ff0000",
26+
Color.YELLOW: "#ffcc00",
27+
Color.GREEN: "#33b989",
28+
}
29+
30+
31+
class BlockKitBuilder:
32+
_SECONDARY_FACT_CHUNK_SIZE = 2
33+
_LONGEST_MARKDOWN_SUFFIX_LEN = 3 # length of markdown's code suffix (```)
34+
35+
def __init__(self) -> None:
36+
self._blocks: List[dict] = []
37+
self._attachment_blocks: List[dict] = []
38+
self._is_divided = False
39+
40+
def _format_icon(self, icon: Icon) -> str:
41+
return ICON_TO_HTML[icon]
42+
43+
def _format_text_block(self, block: TextBlock) -> str:
44+
if block.style == TextStyle.BOLD:
45+
return f"*{block.text}*"
46+
elif block.style == TextStyle.ITALIC:
47+
return f"_{block.text}_"
48+
else:
49+
return block.text
50+
51+
def _format_inline_block(self, block: InlineBlock) -> str:
52+
if isinstance(block, IconBlock):
53+
return self._format_icon(block.icon)
54+
elif isinstance(block, TextBlock):
55+
return self._format_text_block(block)
56+
elif isinstance(block, LinkBlock):
57+
return f"<{block.url}|{block.text}>"
58+
else:
59+
raise ValueError(f"Unsupported inline block type: {type(block)}")
60+
61+
def _format_line_block_text(self, block: LineBlock) -> str:
62+
return block.sep.join(
63+
[self._format_inline_block(inline) for inline in block.inlines]
64+
)
65+
66+
def _format_markdown_section_text(self, text: str) -> dict:
67+
if len(text) > slack_blocks.SectionBlock.text_max_length:
68+
text = (
69+
text[
70+
: slack_blocks.SectionBlock.text_max_length
71+
- len("...")
72+
- self._LONGEST_MARKDOWN_SUFFIX_LEN
73+
]
74+
+ "..."
75+
+ text[-self._LONGEST_MARKDOWN_SUFFIX_LEN :]
76+
)
77+
return {
78+
"type": "mrkdwn",
79+
"text": text,
80+
}
81+
82+
def _format_markdown_section(self, text: str) -> dict:
83+
return {
84+
"type": "section",
85+
"text": self._format_markdown_section_text(text),
86+
}
87+
88+
def _add_block(self, block: dict) -> None:
89+
if not self._is_divided:
90+
self._blocks.append(block)
91+
else:
92+
self._attachment_blocks.append(block)
93+
94+
def _add_lines_block(self, block: LinesBlock) -> None:
95+
formatted_lines = [
96+
self._format_line_block_text(line_block) for line_block in block.lines
97+
]
98+
self._add_block(self._format_markdown_section("\n".join(formatted_lines)))
99+
100+
def _add_header_block(self, block: HeaderBlock) -> None:
101+
if len(block.text) > slack_blocks.HeaderBlock.text_max_length:
102+
text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..."
103+
else:
104+
text = block.text
105+
self._add_block(
106+
{
107+
"type": "header",
108+
"text": {
109+
"type": "plain_text",
110+
"text": text,
111+
},
112+
}
113+
)
114+
115+
def _add_code_block(self, block: CodeBlock) -> None:
116+
self._add_block(self._format_markdown_section(f"```{block.text}```"))
117+
118+
def _add_primary_fact(self, fact: FactBlock) -> None:
119+
self._add_block(
120+
self._format_markdown_section(
121+
f"*{self._format_line_block_text(fact.title)}*\n{self._format_line_block_text(fact.value)}"
122+
)
123+
)
124+
125+
def _add_secondary_facts(self, facts: List[FactBlock]) -> None:
126+
if not facts:
127+
return
128+
self._add_block(
129+
{
130+
"type": "section",
131+
"fields": [
132+
self._format_markdown_section_text(
133+
f"*{self._format_line_block_text(fact.title)}*\n{self._format_line_block_text(fact.value)}"
134+
)
135+
for fact in facts
136+
],
137+
}
138+
)
139+
140+
def _add_fact_list_block(self, block: FactListBlock) -> None:
141+
remaining_facts = block.facts[:]
142+
secondary_facts: List[FactBlock] = []
143+
while remaining_facts:
144+
current_fact = remaining_facts.pop(0)
145+
if current_fact.primary:
146+
self._add_secondary_facts(secondary_facts)
147+
secondary_facts = []
148+
self._add_primary_fact(current_fact)
149+
else:
150+
if len(secondary_facts) >= self._SECONDARY_FACT_CHUNK_SIZE:
151+
self._add_secondary_facts(secondary_facts)
152+
secondary_facts = []
153+
secondary_facts.append(current_fact)
154+
self._add_secondary_facts(secondary_facts)
155+
156+
def _add_divider_block(self, block: DividerBlock) -> None:
157+
self._add_block({"type": "divider"})
158+
self._is_divided = True
159+
160+
def _add_expandable_block(self, block: ExpandableBlock) -> None:
161+
"""
162+
Expandable blocks are not supported in Slack Block Kit.
163+
However, slack automatically collapses a large section block into an expandable block.
164+
"""
165+
self._add_block(
166+
{
167+
"type": "section",
168+
"text": self._format_markdown_section_text(f"*{block.title}*"),
169+
}
170+
)
171+
self._add_message_blocks(block.body)
172+
173+
def _add_message_block(self, block: MessageBlock) -> None:
174+
if isinstance(block, HeaderBlock):
175+
self._add_header_block(block)
176+
elif isinstance(block, CodeBlock):
177+
self._add_code_block(block)
178+
elif isinstance(block, LinesBlock):
179+
self._add_lines_block(block)
180+
elif isinstance(block, FactListBlock):
181+
self._add_fact_list_block(block)
182+
elif isinstance(block, DividerBlock):
183+
self._add_divider_block(block)
184+
elif isinstance(block, ExpandableBlock):
185+
self._add_expandable_block(block)
186+
else:
187+
raise ValueError(f"Unsupported message block type: {type(block)}")
188+
189+
def _add_message_blocks(self, blocks: List[MessageBlock]) -> None:
190+
for block in blocks:
191+
self._add_message_block(block)
192+
193+
def _get_final_blocks(
194+
self, color: Optional[Color]
195+
) -> Tuple[List[dict], List[dict]]:
196+
"""
197+
Slack does not support coloring regular messages, only attachments.
198+
Also, regular messages are always displayed in full, while attachments show the first 5 blocks (with a "show more" button).
199+
The way we handle this is as follows:
200+
- If we have a divider block, everything up to it and including it is a regular message, and everything after it is an attachment.
201+
- If we don't have a divider block:
202+
- If we have a color, everything is an attachment (in order to always display colored messages).
203+
- If we don't have a color, everything is a regular message.
204+
"""
205+
if self._is_divided or not color:
206+
return self._blocks, self._attachment_blocks
207+
else:
208+
return [], self._blocks
209+
210+
def build(self, message: MessageBody) -> Dict[str, Any]:
211+
self._blocks = []
212+
self._attachment_blocks = []
213+
self._add_message_blocks(message.blocks)
214+
color_code = COLOR_MAP.get(message.color) if message.color else None
215+
blocks, attachment_blocks = self._get_final_blocks(message.color)
216+
built_message = {
217+
"blocks": blocks,
218+
"attachments": [
219+
{
220+
"blocks": attachment_blocks,
221+
}
222+
],
223+
}
224+
if color_code:
225+
built_message["attachments"][0]["color"] = color_code
226+
return built_message
227+
228+
229+
def format_block_kit(message: MessageBody) -> Dict[str, Any]:
230+
builder = BlockKitBuilder()
231+
return builder.build(message)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from elementary.messages.blocks import Icon
2+
3+
ICON_TO_HTML = {
4+
Icon.RED_TRIANGLE: "🔺",
5+
Icon.X: "❌",
6+
Icon.WARNING: "⚠️",
7+
Icon.EXCLAMATION: "❗",
8+
Icon.CHECK: "✅",
9+
Icon.MAGNIFYING_GLASS: "🔎",
10+
Icon.HAMMER_AND_WRENCH: "🛠️",
11+
Icon.POLICE_LIGHT: "🚨",
12+
Icon.INFO: "ℹ️",
13+
Icon.EYE: "👁️",
14+
Icon.GEAR: "⚙️",
15+
Icon.BELL: "🔔",
16+
}
17+
18+
for icon in Icon:
19+
if icon not in ICON_TO_HTML:
20+
raise RuntimeError(f"No HTML representation for icon {icon}")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Elementary Messaging Integration System
2+
3+
## Overview
4+
5+
The Elementary Messaging Integration system provides a flexible and extensible framework for sending alerts and messages to various messaging platforms (e.g., Slack, Teams). The system is designed to support a gradual migration from the legacy integration system to a more generic messaging-based approach.
6+
7+
## Architecture
8+
9+
### BaseMessagingIntegration
10+
11+
The core of the new messaging system is the `BaseMessagingIntegration` abstract class. This class defines the contract that all messaging integrations must follow:
12+
13+
- `send_message()`: Send a message to a specific destination
14+
- `supports_reply()`: Check if the integration supports message threading/replies
15+
- `reply_to_message()`: Reply to an existing message (if supported)
16+
17+
### Key Components
18+
19+
1. **MessageBody**: A platform-agnostic representation of a message
20+
2. **MessageSendResult**: Contains information about a sent message, including timestamp and platform-specific context
21+
3. **DestinationType**: Generic type representing the destination for a message (e.g., webhook URL, channel)
22+
4. **MessageContextType**: Generic type for platform-specific message context
23+
24+
## Migration Strategy
25+
26+
The system currently supports both:
27+
28+
- Legacy `BaseIntegration` implementations (e.g., Slack)
29+
- New `BaseMessagingIntegration` implementations (e.g., Teams)
30+
31+
This dual support allows for a gradual migration path where:
32+
33+
1. New integrations are implemented using `BaseMessagingIntegration`
34+
2. Existing integrations can be migrated one at a time
35+
3. The legacy `BaseIntegration` will eventually be deprecated
36+
37+
## Implementing a New Integration
38+
39+
To add a new messaging platform integration:
40+
41+
1. Create a new class that extends `BaseMessagingIntegration`
42+
2. Implement the required abstract methods:
43+
```python
44+
def send_message(self, destination: DestinationType, body: MessageBody) -> MessageSendResult
45+
def supports_reply(self) -> bool
46+
def reply_to_message(self, destination, message_context, message_body) -> MessageSendResult # if supported
47+
```
48+
3. Update the `Integrations` factory class to support the new integration
49+
50+
## Current Implementations
51+
52+
- **Teams**: Uses the new `BaseMessagingIntegration` system with webhook support
53+
- **Slack**: Currently uses the legacy `BaseIntegration` system (planned for migration)
54+
55+
## Future Improvements
56+
57+
1. Complete migration of Slack to `BaseMessagingIntegration`
58+
2. Add support for more messaging platforms

elementary/messages/messaging_integrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)