Skip to content

Commit 2643bcc

Browse files
authored
feat(models): add ContainerBlock for block kit (#1949)
1 parent 19209fe commit 2643bcc

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎slack_sdk/models/blocks/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
CallBlock,
6868
CardBlock,
6969
CarouselBlock,
70+
ContainerBlock,
7071
ContextActionsBlock,
7172
ContextBlock,
7273
DividerBlock,
@@ -139,6 +140,7 @@
139140
"CallBlock",
140141
"CardBlock",
141142
"CarouselBlock",
143+
"ContainerBlock",
142144
"ContextActionsBlock",
143145
"ContextBlock",
144146
"DividerBlock",

‎slack_sdk/models/blocks/blocks.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
113113
return CardBlock(**block)
114114
elif type == AlertBlock.type:
115115
return AlertBlock(**block)
116+
elif type == ContainerBlock.type:
117+
return ContainerBlock(**block)
116118
elif type == CarouselBlock.type:
117119
return CarouselBlock(**block)
118120
else:
@@ -1005,6 +1007,101 @@ def _validate_body_length(self):
10051007
return self.body is None or self.body.text is None or len(self.body.text) <= self.body_max_length
10061008

10071009

1010+
class ContainerBlock(Block):
1011+
type = "container"
1012+
title_max_length = 150
1013+
subtitle_max_length = 150
1014+
child_blocks_max_length = 10
1015+
valid_widths = {"narrow", "standard", "wide", "full"}
1016+
1017+
@property
1018+
def attributes(self) -> Set[str]: # type: ignore[override]
1019+
return super().attributes.union(
1020+
{
1021+
"title",
1022+
"rich_text_title",
1023+
"subtitle",
1024+
"child_blocks",
1025+
"width",
1026+
"icon",
1027+
"is_collapsible",
1028+
"default_collapsed",
1029+
"has_header_divider",
1030+
}
1031+
)
1032+
1033+
def __init__(
1034+
self,
1035+
*,
1036+
child_blocks: Sequence[Union[dict, "Block"]],
1037+
title: Optional[Union[str, dict, PlainTextObject]] = None,
1038+
rich_text_title: Optional[Union[dict, "RichTextBlock"]] = None,
1039+
subtitle: Optional[Union[str, dict, TextObject]] = None,
1040+
width: Optional[str] = None,
1041+
icon: Optional[Union[dict, ImageElement]] = None,
1042+
is_collapsible: Optional[bool] = None,
1043+
default_collapsed: Optional[bool] = None,
1044+
has_header_divider: Optional[bool] = None,
1045+
block_id: Optional[str] = None,
1046+
**others: dict,
1047+
):
1048+
"""A general-purpose wrapper for grouping child blocks together, with a configurable size.
1049+
https://docs.slack.dev/reference/block-kit/blocks/container-block
1050+
1051+
Args:
1052+
child_blocks (required): An array of blocks. Maximum 10 blocks.
1053+
title: Plain text title. Maximum 150 characters. One of title or rich_text_title is required.
1054+
rich_text_title: Rich text title. Takes precedence over title if both are provided.
1055+
subtitle: Plain text or markdown subtitle. Maximum 150 characters.
1056+
width: Controls sizing. One of "narrow", "standard", "wide", or "full". Defaults to "standard".
1057+
icon: An image element displayed alongside the title and subtitle.
1058+
is_collapsible: Whether the container can be collapsed. Defaults to false.
1059+
default_collapsed: Whether the container is collapsed by default. Requires is_collapsible to be true.
1060+
has_header_divider: Whether to show a visible border separating header from content.
1061+
block_id: A unique identifier for a block. If not specified, a block_id will be generated.
1062+
"""
1063+
super().__init__(type=self.type, block_id=block_id)
1064+
show_unknown_key_warning(self, others)
1065+
1066+
self.title = TextObject.parse(title, default_type=PlainTextObject.type) if title is not None else None
1067+
self.rich_text_title = rich_text_title
1068+
self.subtitle = TextObject.parse(subtitle, default_type=PlainTextObject.type) if subtitle is not None else None
1069+
self.child_blocks = Block.parse_all(child_blocks)
1070+
self.width = width
1071+
self.icon = BlockElement.parse(icon) if icon is not None else None
1072+
self.is_collapsible = is_collapsible
1073+
self.default_collapsed = default_collapsed
1074+
self.has_header_divider = has_header_divider
1075+
1076+
@JsonValidator("title or rich_text_title attribute must be specified")
1077+
def _validate_title_or_rich_text_title(self):
1078+
return self.title is not None or self.rich_text_title is not None
1079+
1080+
@JsonValidator(f"title attribute cannot exceed {title_max_length} characters")
1081+
def _validate_title_length(self):
1082+
return self.title is None or self.title.text is None or len(self.title.text) <= self.title_max_length
1083+
1084+
@JsonValidator(f"subtitle attribute cannot exceed {subtitle_max_length} characters")
1085+
def _validate_subtitle_length(self):
1086+
return self.subtitle is None or self.subtitle.text is None or len(self.subtitle.text) <= self.subtitle_max_length
1087+
1088+
@JsonValidator("child_blocks attribute must be specified")
1089+
def _validate_child_blocks_present(self):
1090+
return self.child_blocks is not None and len(self.child_blocks) >= 1
1091+
1092+
@JsonValidator(f"child_blocks attribute cannot exceed {child_blocks_max_length} blocks")
1093+
def _validate_child_blocks_length(self):
1094+
return self.child_blocks is None or len(self.child_blocks) <= self.child_blocks_max_length
1095+
1096+
@JsonValidator("width must be a valid value (narrow, standard, wide, full)")
1097+
def _validate_width(self):
1098+
return self.width is None or self.width in self.valid_widths
1099+
1100+
@JsonValidator("has_header_divider cannot be set when is_collapsible is true")
1101+
def _validate_header_divider_collapsible(self):
1102+
return not (self.is_collapsible is True and self.has_header_divider is True)
1103+
1104+
10081105
class CarouselBlock(Block):
10091106
type = "carousel"
10101107
elements_max_length = 10

‎tests/slack_sdk/models/test_blocks.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CallBlock,
1111
CardBlock,
1212
CarouselBlock,
13+
ContainerBlock,
1314
ContextActionsBlock,
1415
ContextBlock,
1516
DividerBlock,
@@ -1752,6 +1753,135 @@ def test_missing_text(self):
17521753
AlertBlock(text="").validate_json()
17531754

17541755

1756+
class ContainerBlockTests(unittest.TestCase):
1757+
def test_document(self):
1758+
input = {
1759+
"type": "container",
1760+
"title": {"type": "plain_text", "text": "My Container"},
1761+
"child_blocks": [
1762+
{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}},
1763+
{"type": "divider"},
1764+
],
1765+
}
1766+
self.assertDictEqual(input, ContainerBlock(**input).to_dict())
1767+
1768+
def test_parse(self):
1769+
input = {
1770+
"type": "container",
1771+
"title": {"type": "plain_text", "text": "Parsed"},
1772+
"child_blocks": [
1773+
{"type": "header", "text": {"type": "plain_text", "text": "Header"}},
1774+
],
1775+
}
1776+
parsed = Block.parse(input)
1777+
self.assertIsNotNone(parsed)
1778+
self.assertDictEqual(input, parsed.to_dict())
1779+
1780+
def test_all_properties_collapsible(self):
1781+
input = {
1782+
"type": "container",
1783+
"title": {"type": "plain_text", "text": "Full Container"},
1784+
"subtitle": {"type": "mrkdwn", "text": "A subtitle"},
1785+
"child_blocks": [
1786+
{"type": "section", "text": {"type": "mrkdwn", "text": "Content"}},
1787+
],
1788+
"width": "wide",
1789+
"icon": {"type": "image", "image_url": "https://example.com/img.png", "alt_text": "icon"},
1790+
"is_collapsible": True,
1791+
"default_collapsed": True,
1792+
"block_id": "container-1",
1793+
}
1794+
self.assertDictEqual(input, ContainerBlock(**input).to_dict())
1795+
1796+
def test_all_properties_header_divider(self):
1797+
input = {
1798+
"type": "container",
1799+
"title": {"type": "plain_text", "text": "Full Container"},
1800+
"subtitle": {"type": "mrkdwn", "text": "A subtitle"},
1801+
"child_blocks": [
1802+
{"type": "section", "text": {"type": "mrkdwn", "text": "Content"}},
1803+
],
1804+
"width": "wide",
1805+
"icon": {"type": "image", "image_url": "https://example.com/img.png", "alt_text": "icon"},
1806+
"has_header_divider": True,
1807+
"block_id": "container-2",
1808+
}
1809+
self.assertDictEqual(input, ContainerBlock(**input).to_dict())
1810+
1811+
def test_rich_text_title(self):
1812+
input = {
1813+
"type": "container",
1814+
"rich_text_title": {
1815+
"type": "rich_text",
1816+
"elements": [
1817+
{
1818+
"type": "rich_text_section",
1819+
"elements": [{"type": "text", "text": "Rich Title"}],
1820+
}
1821+
],
1822+
},
1823+
"child_blocks": [
1824+
{"type": "divider"},
1825+
],
1826+
}
1827+
self.assertDictEqual(input, ContainerBlock(**input).to_dict())
1828+
1829+
def test_width_values(self):
1830+
for width in ["narrow", "standard", "wide", "full"]:
1831+
input = {
1832+
"type": "container",
1833+
"title": {"type": "plain_text", "text": "Test"},
1834+
"child_blocks": [{"type": "divider"}],
1835+
"width": width,
1836+
}
1837+
ContainerBlock(**input).validate_json()
1838+
1839+
def test_invalid_width(self):
1840+
with self.assertRaises(SlackObjectFormationError):
1841+
ContainerBlock(
1842+
title={"type": "plain_text", "text": "Test"},
1843+
child_blocks=[{"type": "divider"}],
1844+
width="extra_wide",
1845+
).validate_json()
1846+
1847+
def test_title_length_validation(self):
1848+
with self.assertRaises(SlackObjectFormationError):
1849+
ContainerBlock(
1850+
title={"type": "plain_text", "text": "a" * 151},
1851+
child_blocks=[{"type": "divider"}],
1852+
).validate_json()
1853+
1854+
def test_subtitle_length_validation(self):
1855+
with self.assertRaises(SlackObjectFormationError):
1856+
ContainerBlock(
1857+
title={"type": "plain_text", "text": "Title"},
1858+
subtitle={"type": "mrkdwn", "text": "a" * 151},
1859+
child_blocks=[{"type": "divider"}],
1860+
).validate_json()
1861+
1862+
def test_missing_title_and_rich_text_title(self):
1863+
with self.assertRaises(SlackObjectFormationError):
1864+
ContainerBlock(
1865+
child_blocks=[{"type": "divider"}],
1866+
).validate_json()
1867+
1868+
def test_empty_child_blocks(self):
1869+
with self.assertRaises(SlackObjectFormationError):
1870+
ContainerBlock(
1871+
title={"type": "plain_text", "text": "Test"},
1872+
child_blocks=[],
1873+
).validate_json()
1874+
1875+
def test_collapsible_with_header_divider(self):
1876+
with self.assertRaises(SlackObjectFormationError):
1877+
ContainerBlock(
1878+
title={"type": "plain_text", "text": "Test"},
1879+
child_blocks=[{"type": "divider"}],
1880+
is_collapsible=True,
1881+
has_header_divider=True,
1882+
).validate_json()
1883+
1884+
17551885
class CarouselBlockTests(unittest.TestCase):
17561886
def test_document(self):
17571887
input = {

0 commit comments

Comments
 (0)