Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions slack_sdk/models/blocks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Option,
OptionGroup,
PlainTextObject,
RawTextObject,
TextObject,
)
from .block_elements import (
Expand Down Expand Up @@ -71,6 +72,7 @@
MarkdownBlock,
RichTextBlock,
SectionBlock,
TableBlock,
VideoBlock,
)

Expand All @@ -83,6 +85,7 @@
"Option",
"OptionGroup",
"PlainTextObject",
"RawTextObject",
"TextObject",
"BlockElement",
"ButtonElement",
Expand Down Expand Up @@ -133,6 +136,7 @@
"InputBlock",
"MarkdownBlock",
"SectionBlock",
"TableBlock",
"VideoBlock",
"RichTextBlock",
]
39 changes: 39 additions & 0 deletions slack_sdk/models/blocks/basic_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,45 @@ def from_link(link: Link, title: str = "") -> "MarkdownTextObject":
title = f": {title}"
return MarkdownTextObject(text=f"{link}{title}")


class RawTextObject(TextObject):
"""raw_text typed text object (used in table block cells)"""

type = "raw_text"
text_max_length = 3000

@property
def attributes(self) -> Set[str]: # type: ignore[override]
return {"text", "type"}

def __init__(self, *, text: str):
"""A raw text object used in table block cells.
https://docs.slack.dev/reference/block-kit/blocks/table-block

Args:
text (required): The text content for the table cell.
The minimum length is 1 and maximum length is 3000 characters.
"""
super().__init__(text=text, type=self.type)

@staticmethod
def from_str(text: str) -> "RawTextObject":
"""Transforms a string into a RawTextObject"""
return RawTextObject(text=text)

@staticmethod
def direct_from_string(text: str) -> Dict[str, Any]:
"""Transforms a string into the required object shape to act as a RawTextObject"""
return RawTextObject.from_str(text).to_dict()

@JsonValidator(f"text attribute cannot exceed {text_max_length} characters")
def _validate_text_length(self):
return len(self.text) <= self.text_max_length

@JsonValidator("text attribute must have at least 1 character")
def _validate_text_min_length(self):
return len(self.text) >= 1

@staticmethod
def direct_from_link(link: Link, title: str = "") -> Dict[str, Any]:
"""
Expand Down
46 changes: 46 additions & 0 deletions slack_sdk/models/blocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
return VideoBlock(**block)
elif type == RichTextBlock.type:
return RichTextBlock(**block)
elif type == TableBlock.type:
return TableBlock(**block)
else:
cls.logger.warning(f"Unknown block detected and skipped ({block})")
return None
Expand Down Expand Up @@ -730,3 +732,47 @@ def __init__(
show_unknown_key_warning(self, others)

self.elements = BlockElement.parse_all(elements)


class TableBlock(Block):
type = "table"

@property
def attributes(self) -> Set[str]: # type: ignore[override]
return super().attributes.union({"rows", "column_settings"})

def __init__(
self,
*,
rows: Sequence[Sequence[Dict[str, Any]]],
column_settings: Optional[Sequence[Optional[Dict[str, Any]]]] = None,
block_id: Optional[str] = None,
**others: dict,
):
"""Displays structured information in a table.
https://docs.slack.dev/reference/block-kit/blocks/table-block

Args:
rows (required): A 2D array of table cells. Each row is an array of cell objects.
Each cell can be either a raw_text or rich_text element.
column_settings: Optional array of column settings objects to configure text alignment
and wrapping behavior for each column. Use None/null to skip a column.
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
Maximum length for this field is 255 characters.
block_id should be unique for each message and each iteration of a message.
If a message is updated, use a new block_id.

Note:
- Cell format: {"type": "raw_text", "text": "Cell content"} or use RawTextObject helper
- Column settings options: align ("left", "center", "right"), is_wrapped (boolean)
- Tables must be sent in the attachments field, not top-level blocks
"""
super().__init__(type=self.type, block_id=block_id)
show_unknown_key_warning(self, others)

self.rows = rows
self.column_settings = column_settings

@JsonValidator("rows attribute must be specified")
def _validate_rows(self):
return self.rows is not None and len(self.rows) > 0
212 changes: 212 additions & 0 deletions tests/slack_sdk/models/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Option,
OverflowMenuElement,
PlainTextObject,
RawTextObject,
RichTextBlock,
RichTextElementParts,
RichTextListElement,
Expand All @@ -29,6 +30,7 @@
RichTextSectionElement,
SectionBlock,
StaticSelectElement,
TableBlock,
VideoBlock,
)
from slack_sdk.models.blocks.basic_components import FeedbackButtonObject, SlackFile
Expand Down Expand Up @@ -1267,3 +1269,213 @@ def test_parsing_empty_block_elements(self):
self.assertIsNotNone(block_dict["elements"][1].get("elements"))
self.assertIsNotNone(block_dict["elements"][2].get("elements"))
self.assertIsNotNone(block_dict["elements"][3].get("elements"))


# ----------------------------------------------
# RawTextObject
# ----------------------------------------------


class RawTextObjectTests(unittest.TestCase):
def test_basic_creation(self):
"""Test basic RawTextObject creation"""
obj = RawTextObject(text="Hello")
expected = {"type": "raw_text", "text": "Hello"}
self.assertDictEqual(expected, obj.to_dict())

def test_from_str(self):
"""Test RawTextObject.from_str() helper"""
obj = RawTextObject.from_str("Test text")
expected = {"type": "raw_text", "text": "Test text"}
self.assertDictEqual(expected, obj.to_dict())

def test_direct_from_string(self):
"""Test RawTextObject.direct_from_string() helper"""
result = RawTextObject.direct_from_string("Direct text")
expected = {"type": "raw_text", "text": "Direct text"}
self.assertDictEqual(expected, result)

def test_text_length_validation_max(self):
"""Test that text exceeding 3000 characters fails validation"""
with self.assertRaises(SlackObjectFormationError):
RawTextObject(text="a" * 3001).to_dict()

def test_text_length_validation_at_max(self):
"""Test that text at exactly 3000 characters passes validation"""
obj = RawTextObject(text="a" * 3000)
obj.to_dict() # Should not raise

def test_text_length_validation_min(self):
"""Test that empty text fails validation"""
with self.assertRaises(SlackObjectFormationError):
RawTextObject(text="").to_dict()

def test_text_length_validation_at_min(self):
"""Test that text with 1 character passes validation"""
obj = RawTextObject(text="a")
obj.to_dict() # Should not raise

def test_attributes(self):
"""Test that RawTextObject only has text and type attributes"""
obj = RawTextObject(text="Test")
self.assertEqual(obj.attributes, {"text", "type"})
# Should not have emoji attribute like PlainTextObject
self.assertNotIn("emoji", obj.to_dict())


# ----------------------------------------------
# Table
# ----------------------------------------------


class TableBlockTests(unittest.TestCase):
def test_document(self):
"""Test basic table block from Slack documentation example"""
input = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Header A"}, {"type": "raw_text", "text": "Header B"}],
[{"type": "raw_text", "text": "Data 1A"}, {"type": "raw_text", "text": "Data 1B"}],
[{"type": "raw_text", "text": "Data 2A"}, {"type": "raw_text", "text": "Data 2B"}],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_with_rich_text(self):
"""Test table block with rich_text cells"""
input = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Header A"}, {"type": "raw_text", "text": "Header B"}],
[
{"type": "raw_text", "text": "Data 1A"},
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [{"text": "Data 1B", "type": "link", "url": "https://slack.com"}],
}
],
},
],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())
self.assertDictEqual(input, Block.parse(input).to_dict())

def test_minimal_table(self):
"""Test table with only required fields"""
input = {
"type": "table",
"rows": [[{"type": "raw_text", "text": "Cell"}]],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_with_block_id(self):
"""Test table block with block_id"""
input = {
"type": "table",
"block_id": "table-123",
"rows": [
[{"type": "raw_text", "text": "A"}, {"type": "raw_text", "text": "B"}],
[{"type": "raw_text", "text": "1"}, {"type": "raw_text", "text": "2"}],
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_column_settings_variations(self):
"""Test various column_settings configurations"""
# Left align
input1 = {
"type": "table",
"column_settings": [{"align": "left"}],
"rows": [[{"type": "raw_text", "text": "Left"}]],
}
self.assertDictEqual(input1, TableBlock(**input1).to_dict())

# Center align
input2 = {
"type": "table",
"column_settings": [{"align": "center"}],
"rows": [[{"type": "raw_text", "text": "Center"}]],
}
self.assertDictEqual(input2, TableBlock(**input2).to_dict())

# With wrapping
input3 = {
"type": "table",
"column_settings": [{"is_wrapped": False}],
"rows": [[{"type": "raw_text", "text": "No wrap"}]],
}
self.assertDictEqual(input3, TableBlock(**input3).to_dict())

# Combined settings
input4 = {
"type": "table",
"column_settings": [{"align": "center", "is_wrapped": True}],
"rows": [[{"type": "raw_text", "text": "Both"}]],
}
self.assertDictEqual(input4, TableBlock(**input4).to_dict())

def test_column_settings_with_none(self):
"""Test column_settings with None to skip columns"""
input = {
"type": "table",
"column_settings": [{"align": "left"}, None, {"align": "right"}],
"rows": [
[
{"type": "raw_text", "text": "Left"},
{"type": "raw_text", "text": "Default"},
{"type": "raw_text", "text": "Right"},
]
],
}
self.assertDictEqual(input, TableBlock(**input).to_dict())

def test_rows_validation(self):
"""Test that rows validation works correctly"""
# Empty rows should fail validation
with self.assertRaises(SlackObjectFormationError):
TableBlock(rows=[]).to_dict()

def test_multi_row_table(self):
"""Test table with multiple rows"""
input = {
"type": "table",
"rows": [
[{"type": "raw_text", "text": "Name"}, {"type": "raw_text", "text": "Age"}],
[{"type": "raw_text", "text": "Alice"}, {"type": "raw_text", "text": "30"}],
[{"type": "raw_text", "text": "Bob"}, {"type": "raw_text", "text": "25"}],
[{"type": "raw_text", "text": "Charlie"}, {"type": "raw_text", "text": "35"}],
],
}
block = TableBlock(**input)
self.assertEqual(len(block.rows), 4)
self.assertDictEqual(input, block.to_dict())

def test_with_raw_text_object_helper(self):
"""Test table using RawTextObject helper class"""
# Create table using RawTextObject helper
block = TableBlock(
rows=[
[RawTextObject(text="Product").to_dict(), RawTextObject(text="Price").to_dict()],
[RawTextObject(text="Widget").to_dict(), RawTextObject(text="$10").to_dict()],
[RawTextObject(text="Gadget").to_dict(), RawTextObject(text="$20").to_dict()],
],
column_settings=[{"is_wrapped": True}, {"align": "right"}],
)

expected = {
"type": "table",
"column_settings": [{"is_wrapped": True}, {"align": "right"}],
"rows": [
[{"type": "raw_text", "text": "Product"}, {"type": "raw_text", "text": "Price"}],
[{"type": "raw_text", "text": "Widget"}, {"type": "raw_text", "text": "$10"}],
[{"type": "raw_text", "text": "Gadget"}, {"type": "raw_text", "text": "$20"}],
],
}
self.assertDictEqual(expected, block.to_dict())
Loading
Loading