Skip to content

Commit f62b3e3

Browse files
authored
Merge pull request #1949 from elementary-data/ele-4704-text-and-md-formatters
text and markdown formats
2 parents ded6ea5 + 9da38c9 commit f62b3e3

File tree

89 files changed

+775
-21
lines changed

Some content is hidden

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

89 files changed

+775
-21
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ repos:
1919
rev: "v3.0.0"
2020
hooks:
2121
- id: prettier
22-
exclude: \.html$|^docs/
22+
exclude: \.html$|^docs/|^tests/unit/messages/formats/markdown/fixtures/
2323

2424
- repo: https://github.com/crate-ci/typos
2525
rev: v1.16.6

elementary/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tests/unit/messages/formats/markdown/fixtures/*

elementary/messages/blocks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class ActionsBlock(BaseBlock):
113113
"LineBlock",
114114
]
115115

116-
LineBlock.update_forward_refs()
116+
LineBlock.model_rebuild()
117117

118118

119119
class HeaderBlock(BaseBlock):
@@ -184,4 +184,4 @@ class ExpandableBlock(BaseBlock):
184184
]
185185

186186
# Update forward references for recursive types
187-
ExpandableBlock.update_forward_refs()
187+
ExpandableBlock.model_rebuild()

elementary/messages/formats/adaptive_cards.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
TextStyle,
2222
WhitespaceBlock,
2323
)
24-
from elementary.messages.formats.html import ICON_TO_HTML
24+
from elementary.messages.formats.unicode import ICON_TO_UNICODE
2525
from elementary.messages.message_body import Color, MessageBlock, MessageBody
2626

2727
COLOR_TO_STYLE = {
@@ -32,7 +32,7 @@
3232

3333

3434
def format_icon(icon: Icon) -> str:
35-
return ICON_TO_HTML[icon]
35+
return ICON_TO_UNICODE[icon]
3636

3737

3838
def format_text_block(block: TextBlock) -> str:

elementary/messages/formats/block_kit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
UserSelectActionBlock,
3030
WhitespaceBlock,
3131
)
32-
from elementary.messages.formats.html import ICON_TO_HTML
32+
from elementary.messages.formats.unicode import ICON_TO_UNICODE
3333
from elementary.messages.message_body import Color, MessageBlock, MessageBody
3434

3535
COLOR_MAP = {
@@ -61,7 +61,7 @@ def __init__(
6161
self._resolve_mention = resolve_mention or (lambda x: None)
6262

6363
def _format_icon(self, icon: Icon) -> str:
64-
return ICON_TO_HTML[icon]
64+
return ICON_TO_UNICODE[icon]
6565

6666
def _format_text_block(self, block: TextBlock) -> str:
6767
if block.style == TextStyle.BOLD:
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import json
2+
import re
3+
from enum import Enum
4+
5+
from tabulate import tabulate
6+
7+
from elementary.messages.blocks import (
8+
ActionsBlock,
9+
CodeBlock,
10+
DividerBlock,
11+
ExpandableBlock,
12+
FactListBlock,
13+
HeaderBlock,
14+
Icon,
15+
IconBlock,
16+
InlineBlock,
17+
InlineCodeBlock,
18+
LineBlock,
19+
LinesBlock,
20+
LinkBlock,
21+
MentionBlock,
22+
TableBlock,
23+
TextBlock,
24+
TextStyle,
25+
WhitespaceBlock,
26+
)
27+
from elementary.messages.formats.unicode import ICON_TO_UNICODE
28+
from elementary.messages.message_body import MessageBlock, MessageBody
29+
30+
31+
class TableStyle(Enum):
32+
TABULATE = "tabulate"
33+
JSON = "json"
34+
35+
36+
class MarkdownFormatter:
37+
def __init__(self, table_style: TableStyle):
38+
self._table_style = table_style
39+
40+
def format_icon(self, icon: Icon) -> str:
41+
return ICON_TO_UNICODE[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.text}]({block.url})"
58+
elif isinstance(block, InlineCodeBlock):
59+
return f"`{block.code}`"
60+
elif isinstance(block, MentionBlock):
61+
return block.user
62+
elif isinstance(block, LineBlock):
63+
return self.format_line_block(block)
64+
elif isinstance(block, WhitespaceBlock):
65+
return " "
66+
else:
67+
raise ValueError(f"Unsupported inline block type: {type(block)}")
68+
69+
def format_line_block(self, block: LineBlock) -> str:
70+
return block.sep.join(
71+
[self.format_inline_block(inline) for inline in block.inlines]
72+
)
73+
74+
def format_lines_block(self, block: LinesBlock) -> str:
75+
formatted_parts = []
76+
for index, line_block in enumerate(block.lines):
77+
formatted_line = self.format_line_block(line_block)
78+
formatted_parts.append(formatted_line)
79+
is_bullet = re.match(r"^\s*[*-]", formatted_line)
80+
is_last = index == len(block.lines) - 1
81+
if not is_bullet and not is_last:
82+
# in markdown, single line breaks are not rendered as new lines, except for bullet lists
83+
# so we need to add a backslash to force a new line
84+
formatted_parts.append("\\")
85+
if not is_last:
86+
formatted_parts.append("\n")
87+
return "".join(formatted_parts)
88+
89+
def format_fact_list_block(self, block: FactListBlock) -> str:
90+
facts = [
91+
f"{self.format_line_block(fact.title)}: {self.format_line_block(fact.value)}"
92+
for fact in block.facts
93+
]
94+
return " | ".join(facts)
95+
96+
def format_table_block(self, block: TableBlock) -> str:
97+
if self._table_style == TableStyle.TABULATE:
98+
table = tabulate(block.rows, headers=block.headers, tablefmt="simple")
99+
return f"```\n{table}\n```"
100+
elif self._table_style == TableStyle.JSON:
101+
dicts = [
102+
{header: cell for header, cell in zip(block.headers, row)}
103+
for row in block.rows
104+
]
105+
return f"```\n{json.dumps(dicts, indent=2)}\n```"
106+
else:
107+
raise ValueError(f"Invalid table style: {self._table_style}")
108+
109+
def format_expandable_block(self, block: ExpandableBlock) -> str:
110+
body = self.format_message_blocks(block.body)
111+
quoted_body = "\n> ".join(body.split("\n"))
112+
return f"> **{block.title}**\\\n> {quoted_body}"
113+
114+
def format_message_block(self, block: MessageBlock) -> str:
115+
if isinstance(block, HeaderBlock):
116+
return f"# {block.text}"
117+
elif isinstance(block, CodeBlock):
118+
return f"```\n{block.text}\n```"
119+
elif isinstance(block, LinesBlock):
120+
return self.format_lines_block(block)
121+
elif isinstance(block, FactListBlock):
122+
return self.format_fact_list_block(block)
123+
elif isinstance(block, ExpandableBlock):
124+
return self.format_expandable_block(block)
125+
elif isinstance(block, TableBlock):
126+
return self.format_table_block(block)
127+
elif isinstance(block, DividerBlock):
128+
return "---"
129+
elif isinstance(block, ActionsBlock):
130+
# Actions not supported for text
131+
return ""
132+
else:
133+
raise ValueError(f"Unsupported message block type: {type(block)}")
134+
135+
def format_message_blocks(self, blocks: list[MessageBlock]) -> str:
136+
if not blocks:
137+
return ""
138+
return "\n\n".join([self.format_message_block(block) for block in blocks])
139+
140+
def format(self, message: MessageBody) -> str:
141+
return self.format_message_blocks(message.blocks)
142+
143+
144+
def format_markdown(
145+
message: MessageBody, table_style: TableStyle = TableStyle.TABULATE
146+
) -> str:
147+
formatter = MarkdownFormatter(table_style)
148+
return formatter.format(message)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import json
2+
from enum import Enum
3+
from typing import List
4+
5+
from tabulate import tabulate
6+
7+
from elementary.messages.blocks import (
8+
ActionsBlock,
9+
CodeBlock,
10+
DividerBlock,
11+
ExpandableBlock,
12+
FactListBlock,
13+
HeaderBlock,
14+
Icon,
15+
IconBlock,
16+
InlineBlock,
17+
InlineCodeBlock,
18+
LineBlock,
19+
LinesBlock,
20+
LinkBlock,
21+
MentionBlock,
22+
TableBlock,
23+
TextBlock,
24+
WhitespaceBlock,
25+
)
26+
from elementary.messages.formats.unicode import ICON_TO_UNICODE
27+
from elementary.messages.message_body import MessageBlock, MessageBody
28+
29+
30+
class IconStyle(Enum):
31+
UNICODE = "unicode"
32+
NAME = "name"
33+
OMIT = "omit"
34+
35+
36+
class TableStyle(Enum):
37+
TABULATE = "tabulate"
38+
JSON = "json"
39+
40+
41+
class TextFormatter:
42+
def __init__(self, icon_style: IconStyle, table_style: TableStyle):
43+
self._icon_style = icon_style
44+
self._table_style = table_style
45+
46+
def format_icon(self, icon: Icon) -> str:
47+
if self._icon_style == IconStyle.OMIT:
48+
return ""
49+
elif self._icon_style == IconStyle.UNICODE:
50+
return ICON_TO_UNICODE[icon]
51+
elif self._icon_style == IconStyle.NAME:
52+
return f":{icon.value}:"
53+
else:
54+
raise ValueError(f"Invalid icon style: {self._icon_style}")
55+
56+
def format_inline_block(self, block: InlineBlock) -> str:
57+
if isinstance(block, IconBlock):
58+
return self.format_icon(block.icon)
59+
elif isinstance(block, TextBlock):
60+
return block.text
61+
elif isinstance(block, LinkBlock):
62+
return f"{block.text} ({block.url})"
63+
elif isinstance(block, InlineCodeBlock):
64+
return block.code
65+
elif isinstance(block, MentionBlock):
66+
return block.user
67+
elif isinstance(block, LineBlock):
68+
return self.format_line_block(block)
69+
elif isinstance(block, WhitespaceBlock):
70+
return " "
71+
else:
72+
raise ValueError(f"Unsupported inline block type: {type(block)}")
73+
74+
def format_line_block(self, block: LineBlock) -> str:
75+
return block.sep.join(
76+
[self.format_inline_block(inline) for inline in block.inlines]
77+
)
78+
79+
def format_lines_block(self, block: LinesBlock) -> str:
80+
return "\n".join(
81+
[self.format_line_block(line_block) for line_block in block.lines]
82+
)
83+
84+
def format_fact_list_block(self, block: FactListBlock) -> str:
85+
facts = [
86+
f"{self.format_line_block(fact.title)}: {self.format_line_block(fact.value)}"
87+
for fact in block.facts
88+
]
89+
return " | ".join(facts)
90+
91+
def format_table_block(self, block: TableBlock) -> str:
92+
if self._table_style == TableStyle.TABULATE:
93+
return tabulate(block.rows, headers=block.headers, tablefmt="simple")
94+
elif self._table_style == TableStyle.JSON:
95+
dicts = [
96+
{header: cell for header, cell in zip(block.headers, row)}
97+
for row in block.rows
98+
]
99+
return json.dumps(dicts, indent=2)
100+
else:
101+
raise ValueError(f"Invalid table style: {self._table_style}")
102+
103+
def format_expandable_block(self, block: ExpandableBlock) -> str:
104+
return f"{block.title}\n{self.format_message_blocks(block.body)}"
105+
106+
def format_message_block(self, block: MessageBlock) -> str:
107+
if isinstance(block, (HeaderBlock, CodeBlock)):
108+
return block.text
109+
elif isinstance(block, LinesBlock):
110+
return self.format_lines_block(block)
111+
elif isinstance(block, FactListBlock):
112+
return self.format_fact_list_block(block)
113+
elif isinstance(block, ExpandableBlock):
114+
return self.format_expandable_block(block)
115+
elif isinstance(block, TableBlock):
116+
return self.format_table_block(block)
117+
elif isinstance(block, ActionsBlock):
118+
# Actions not supported for text
119+
return ""
120+
elif isinstance(block, DividerBlock):
121+
return "--------------------------------"
122+
else:
123+
raise ValueError(f"Unsupported message block type: {type(block)}")
124+
125+
def format_message_blocks(self, blocks: List[MessageBlock]) -> str:
126+
if not blocks:
127+
return ""
128+
return "\n".join([self.format_message_block(block) for block in blocks])
129+
130+
def format(self, message: MessageBody) -> str:
131+
return self.format_message_blocks(message.blocks)
132+
133+
134+
def format_text(
135+
message: MessageBody,
136+
icon_style: IconStyle = IconStyle.UNICODE,
137+
table_style: TableStyle = TableStyle.TABULATE,
138+
) -> str:
139+
formatter = TextFormatter(icon_style, table_style)
140+
return formatter.format(message)
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from elementary.messages.blocks import Icon
22

3-
ICON_TO_HTML = {
3+
ICON_TO_UNICODE = {
44
Icon.RED_TRIANGLE: "🔺",
55
Icon.X: "❌",
66
Icon.WARNING: "⚠️",
@@ -18,5 +18,5 @@
1818
}
1919

2020
for icon in Icon:
21-
if icon not in ICON_TO_HTML:
22-
raise RuntimeError(f"No HTML representation for icon {icon}")
21+
if icon not in ICON_TO_UNICODE:
22+
raise RuntimeError(f"No unicode representation for icon {icon}")

elementary/messages/message_body.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ class MessageBody(BaseModel):
3939
id: Optional[str] = None
4040

4141

42-
MessageBody.update_forward_refs()
42+
MessageBody.model_rebuild()

0 commit comments

Comments
 (0)