diff --git a/elementary/messages/README.md b/elementary/messages/README.md new file mode 100644 index 000000000..8dbfe0f7a --- /dev/null +++ b/elementary/messages/README.md @@ -0,0 +1,130 @@ +# Elementary Messages + +This package provides a flexible system for building structured messages that can be rendered across different platforms (like Slack, Teams, etc.). +The system defines a minimal and simple format that can be formatted into different output formats.However, some blocks, styles, or icons might not be supported across all platforms. + +## Core Components + +- `MessageBody`: The root container for a message +- `blocks`: Building blocks for message content (headers, code blocks, text, etc.) +- `block_builders`: Helper functions to easily construct common block patterns + +## Message Structure + +A `MessageBody` can contain the following blocks directly: + +- `HeaderBlock` +- `CodeBlock` +- `DividerBlock` +- `LinesBlock` +- `FactListBlock` +- `ExpandableBlock` + +## Basic Usage + +```python +from elementary.messages.message_body import MessageBody, Color +from elementary.messages.blocks import HeaderBlock, LinesBlock, TextBlock +from elementary.messages.block_builders import TextLineBlock, BoldTextLineBlock + +# Create a simple message +message = MessageBody( + blocks=[ + HeaderBlock(text="My Message Title"), + TextLineBlock(text="This is a simple text line"), + BoldTextLineBlock(text="This is bold text"), + ], + color=Color.GREEN +) +``` + +## Available Blocks + +### Text and Layout + +- `HeaderBlock`: Section headers +- `TextBlock`: Basic text content with optional style (BOLD/ITALIC) +- `LineBlock`: A line that can contain multiple inline elements: + - `TextBlock`: Text with optional styling + - `LinkBlock`: Clickable URL with display text + - `IconBlock`: Visual indicator (like ✓, ⚠️, etc.) +- `LinesBlock`: Multiple lines of text +- `DividerBlock`: Visual separator +- `CodeBlock`: Code snippets or formatted text + +### Interactive Elements + +- `ExpandableBlock`: Collapsible sections +- `LinkBlock`: Clickable URLs + +### Data Display + +- `FactBlock`: Key-value pairs +- `FactListBlock`: Collection of facts + +### Styling + +- `TextStyle`: Text formatting (BOLD, ITALIC) +- `Icon`: Various icons for visual indicators +- `Color`: Message theme colors (RED, YELLOW, GREEN) + +## Block Builders + +The `block_builders` module provides convenient functions for creating common block patterns. These builders are wrappers around the basic blocks defined in the `blocks` module and help with constructing commonly used block combinations: + +```python +from elementary.messages.block_builders import ( + BulletListBlock, + FactsBlock, + JsonCodeBlock, + TitledParagraphBlock +) +from elementary.messages.blocks import Icon + +# Create a bullet list +bullet_list = BulletListBlock( + icon=Icon.CHECK, + lines=[TextLineBlock(text="Item 1"), TextLineBlock(text="Item 2")] +) + +# Create facts list +facts = FactsBlock( + facts=[ + ("Status", "Passed"), + ("Duration", "2.5s"), + ] +) + +# Create JSON code block +json_block = JsonCodeBlock( + content={"key": "value"} +) + +# Create titled paragraph +paragraph = TitledParagraphBlock( + title="Section Title", + lines=[TextLineBlock(text="Paragraph content")] +) + +# Example of a line with multiple inline elements +from elementary.messages.blocks import LineBlock, TextBlock, LinkBlock, IconBlock, Icon, TextStyle + +complex_line = LineBlock( + inlines=[ + IconBlock(icon=Icon.CHECK), + TextBlock(text="Test passed - ", style=TextStyle.BOLD), + TextBlock(text="View details at "), + LinkBlock(text="dashboard", url="https://example.com"), + ] +) +``` + +## Message Formatting + +To format messages into different output formats (like Slack or Teams), a formatter needs to support all the basic blocks defined in the `blocks` module: + +- Core blocks: `HeaderBlock`, `CodeBlock`, `DividerBlock`, `LinesBlock`, `FactListBlock`, `ExpandableBlock` +- Inline blocks: `TextBlock`, `LinkBlock`, `IconBlock` +- Styling: `TextStyle`, `Color` + +The block builders are convenience wrappers that ultimately create these basic blocks, so formatters only need to handle the core block types. diff --git a/elementary/messages/__init__.py b/elementary/messages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/elementary/messages/block_builders.py b/elementary/messages/block_builders.py new file mode 100644 index 000000000..31c8d9ba2 --- /dev/null +++ b/elementary/messages/block_builders.py @@ -0,0 +1,122 @@ +import json +from typing import List, Optional, Tuple, Union + +from .blocks import ( + CodeBlock, + FactBlock, + FactListBlock, + Icon, + IconBlock, + InlineBlock, + LineBlock, + LinesBlock, + LinkBlock, + TextBlock, + TextStyle, +) + +SimpleInlineBlock = Union[str, Icon] +SimpleLineBlock = Union[str, Icon, List[SimpleInlineBlock]] + + +def _build_inline_block( + content: SimpleInlineBlock, style: Optional[TextStyle] = None +) -> InlineBlock: + return IconBlock(icon=content) if isinstance(content, Icon) else TextBlock(text=content, style=style) # type: ignore + + +def _build_inlines( + content: SimpleLineBlock, style: Optional[TextStyle] = None +) -> List[InlineBlock]: + if isinstance(content, list): + return [_build_inline_block(line, style) for line in content] + return [_build_inline_block(content)] + + +def BulletListBlock( + *, + icon: Union[Icon, str], + lines: List[LineBlock] = [], +) -> LinesBlock: + icon_inline: InlineBlock = ( + IconBlock(icon=icon) if isinstance(icon, Icon) else TextBlock(text=icon) + ) + lines = [LineBlock(inlines=[icon_inline] + line.inlines) for line in lines] + return LinesBlock(lines=lines) + + +def BoldTextBlock(*, text: str) -> TextBlock: + return TextBlock(text=text, style=TextStyle.BOLD) + + +def ItalicTextBlock(*, text: str) -> TextBlock: + return TextBlock(text=text, style=TextStyle.ITALIC) + + +def TextLineBlock( + *, text: SimpleLineBlock, style: Optional[TextStyle] = None +) -> LineBlock: + return LineBlock(inlines=_build_inlines(text, style)) + + +def BoldTextLineBlock(*, text: SimpleLineBlock) -> LineBlock: + return TextLineBlock(text=text, style=TextStyle.BOLD) + + +def ItalicTextLineBlock(*, text: SimpleLineBlock) -> LineBlock: + return TextLineBlock(text=text, style=TextStyle.ITALIC) + + +def LinkLineBlock(*, text: str, url: str) -> LineBlock: + return LineBlock(inlines=[LinkBlock(text=text, url=url)]) + + +def SummaryLineBlock( + *, + summary: List[Tuple[SimpleLineBlock, SimpleLineBlock]], + include_empty_values: bool = False, +) -> LineBlock: + text_blocks: List[InlineBlock] = [] + for title, value in summary: + if not value and not include_empty_values: + continue + text_blocks.extend(_build_inlines(title, TextStyle.BOLD)) + text_blocks.extend(_build_inlines(value)) + text_blocks.append(TextBlock(text="|")) + text_blocks = text_blocks[:-1] + return LineBlock(inlines=text_blocks) + + +def FactsBlock( + *, + facts: List[ + Tuple[ + SimpleLineBlock, + SimpleLineBlock, + ] + ], + include_empty_values: bool = False, +) -> FactListBlock: + return FactListBlock( + facts=[ + FactBlock( + title=LineBlock(inlines=_build_inlines(title)), + value=LineBlock(inlines=_build_inlines(value)), + ) + for title, value in facts + if value or include_empty_values + ] + ) + + +def TitledParagraphBlock( + *, + title: SimpleLineBlock, + lines: List[LineBlock], +) -> LinesBlock: + title_line = LineBlock(inlines=_build_inlines(title, TextStyle.BOLD)) + return LinesBlock(lines=[title_line] + lines) + + +def JsonCodeBlock(*, content: Union[str, dict, list], indent: int = 2) -> CodeBlock: + return CodeBlock(text=json.dumps(content, indent=indent)) diff --git a/elementary/messages/blocks.py b/elementary/messages/blocks.py new file mode 100644 index 000000000..ec0b3201e --- /dev/null +++ b/elementary/messages/blocks.py @@ -0,0 +1,101 @@ +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel + + +class Icon(Enum): + RED_TRIANGLE = "red_triangle" + X = "x" + WARNING = "warning" + EXCLAMATION = "exclamation" + CHECK = "check" + MAGNIFYING_GLASS = "magnifying_glass" + HAMMER_AND_WRENCH = "hammer_and_wrench" + POLICE_LIGHT = "police_light" + INFO = "info" + EYE = "eye" + GEAR = "gear" + BELL = "bell" + + +class TextStyle(Enum): + BOLD = "bold" + ITALIC = "italic" + + +class BaseBlock(BaseModel): + pass + + +class BaseInlineTextBlock(BaseBlock): + pass + + +class TextBlock(BaseInlineTextBlock): + text: str + style: Optional[TextStyle] = None + + +class LinkBlock(BaseInlineTextBlock): + text: str + url: str + + +class IconBlock(BaseInlineTextBlock): + icon: Icon + + +InlineBlock = Union[TextBlock, LinkBlock, IconBlock] + + +class HeaderBlock(BaseBlock): + text: str + + +class CodeBlock(BaseBlock): + text: str + + +class DividerBlock(BaseBlock): + pass + + +class LineBlock(BaseBlock): + inlines: List[InlineBlock] + sep: str = " " + + +class BaseLinesBlock(BaseBlock): + lines: List[LineBlock] + + +class LinesBlock(BaseLinesBlock): + pass + + +class FactBlock(BaseBlock): + title: LineBlock + value: LineBlock + + +class FactListBlock(BaseBlock): + facts: List[FactBlock] + + +class ExpandableBlock(BaseBlock): + title: str + body: List["InExpandableBlock"] + expanded: bool = False + + +InExpandableBlock = Union[ + HeaderBlock, + CodeBlock, + DividerBlock, + LinesBlock, + FactListBlock, + "ExpandableBlock", +] + +ExpandableBlock.model_rebuild() diff --git a/elementary/messages/message_body.py b/elementary/messages/message_body.py new file mode 100644 index 000000000..d2958727a --- /dev/null +++ b/elementary/messages/message_body.py @@ -0,0 +1,37 @@ +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel + +from elementary.messages.blocks import ( + CodeBlock, + DividerBlock, + ExpandableBlock, + FactListBlock, + HeaderBlock, + LinesBlock, +) + + +class Color(Enum): + RED = "red" + YELLOW = "yellow" + GREEN = "green" + + +MessageBlock = Union[ + HeaderBlock, + CodeBlock, + DividerBlock, + LinesBlock, + FactListBlock, + ExpandableBlock, +] + + +class MessageBody(BaseModel): + blocks: List[MessageBlock] + color: Optional[Color] = None + + +MessageBody.model_rebuild()