slackblocks 3.0.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {slackblocks-3.0.0 → slackblocks-3.2.0}/.gitignore +5 -0
  2. {slackblocks-3.0.0 → slackblocks-3.2.0}/CHANGELOG.md +11 -0
  3. {slackblocks-3.0.0 → slackblocks-3.2.0}/PKG-INFO +5 -5
  4. {slackblocks-3.0.0 → slackblocks-3.2.0}/README.md +4 -4
  5. {slackblocks-3.0.0 → slackblocks-3.2.0}/pyproject.toml +1 -1
  6. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/__init__.py +17 -1
  7. slackblocks-3.2.0/slackblocks/_layout.py +56 -0
  8. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_limits.py +12 -0
  9. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_surfaces.py +6 -4
  10. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/blocks.py +205 -2
  11. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/messages.py +12 -15
  12. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/views.py +4 -1
  13. {slackblocks-3.0.0 → slackblocks-3.2.0}/uv.lock +5 -5
  14. {slackblocks-3.0.0 → slackblocks-3.2.0}/LICENSE +0 -0
  15. {slackblocks-3.0.0 → slackblocks-3.2.0}/LICENSE.BSD-3-Clause +0 -0
  16. {slackblocks-3.0.0 → slackblocks-3.2.0}/conformance/skiplist.txt +0 -0
  17. {slackblocks-3.0.0 → slackblocks-3.2.0}/generator/generate_limits.py +0 -0
  18. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_core.py +0 -0
  19. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/attachments.py +0 -0
  20. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/builder.py +0 -0
  21. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/elements.py +0 -0
  22. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/errors.py +0 -0
  23. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/modals.py +0 -0
  24. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/objects.py +0 -0
  25. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/py.typed +0 -0
  26. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/__init__.py +0 -0
  27. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/elements.py +0 -0
  28. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/objects.py +0 -0
  29. {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/utils.py +0 -0
@@ -136,3 +136,8 @@ docs/docs/reference/php/
136
136
  !/elixir/integrations/lib/**
137
137
 
138
138
  /docs/docs/reference/elixir/
139
+
140
+ /docs/docs/reference/haskell/
141
+
142
+ # Prepared Hackage source/docs assets are CI artifacts.
143
+ haskell/dist-release/
@@ -5,6 +5,17 @@ All notable changes to `slackblocks` are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.2.0] — 2026-10-31
9
+
10
+ - Add native layout blocks, rows and items with typed widths/alignment, closed child and fallback roles, checked nesting and receiving-surface validation (spec 1.5.0).
11
+ - Preserve confirmed top-level message/view block counting, supported runtime minimums, legacy container-only behavior, and existing chart/text limits. No unconfirmed recursive block budget is imposed.
12
+ - Add a runnable ten-language layout recipe and rendered preview; preserve outgoing 3.1.0 documentation.
13
+ - Preserve real nested layout/container/fallback locations in surface-validation errors. `Block.from_dict` recognizes layout and raises the documented `NotImplementedError` for unsupported nested parsing.
14
+
15
+ ## [3.1.0] — 2026-10-28
16
+
17
+ - Coordinate this release with the new Haskell implementation and ten-language documentation. Existing runtime support remains unchanged.
18
+
8
19
  ## [3.0.0] — 2026-10-24
9
20
 
10
21
  - Add native button `agent_prompt` and `agent_prompt_display` fields, Unicode prompt-length validation and the display/prompt dependency (spec 1.4.0). Document interaction acknowledgement regardless of Slackbot AI availability. Existing button calls remain compatible.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: slackblocks
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Python wrapper for the Slack Blocks API
5
5
  Project-URL: Homepage, https://github.com/nicklambourne/slackblocks
6
6
  Project-URL: Repository, https://github.com/nicklambourne/slackblocks
@@ -113,14 +113,14 @@ The `**` operator unpacks `slackblocks` `Message` objects directly into the SDK
113
113
 
114
114
  | Surface | Status |
115
115
  |---------------------|:------:|
116
- | Blocks | ✅ All current block types, including Alert, Card, Carousel, Container, Context Actions, Data Table, Data Visualization, Plan, and Task Card |
116
+ | Blocks | ✅ All current block types, including Alert, Card, Carousel, Container, Layout (3.2.0+), Context Actions, Data Table, Data Visualization, Plan, and Task Card |
117
117
  | Elements | ✅ Buttons, all select menus, date/time pickers, checkboxes, radio groups, all input types, overflow menus, workflow buttons |
118
118
  | Composition Objects | ✅ Text (+ `PlainText` / `Markdown` aliases), Option, Confirm, Conversation/Dispatch filters, Workflow, Trigger |
119
119
  | Rich Text | ✅ Sections, lists, quotes, code blocks, inline links/users/channels/emoji |
120
120
  | Modals & Home Tabs | ✅ Full views API |
121
121
  | Messages | ✅ `chat.postMessage`, webhook messages, slash-command/interaction responses, threaded replies, ephemeral messages |
122
122
  | Attachments | ⚠️ Supported but [deprecated by Slack](https://api.slack.com/reference/messaging/attachments) |
123
- | Round-tripping | ✅ `Block.from_dict(data)` and per-class `from_dict` for parsing incoming Slack JSON back into objects |
123
+ | Round-tripping | ⚠️ Partial `Block.from_dict(data)` parsing; composite blocks including Layout raise `NotImplementedError`. See the [support table](https://nicklambourne.github.io/slackblocks/usage/migration#round-tripping-incoming-slack-json-with-from_dict) |
124
124
 
125
125
  ## What's new in 2.0
126
126
 
@@ -130,7 +130,7 @@ The `**` operator unpacks `slackblocks` `Message` objects directly into the SDK
130
130
  - **`PlainText` and `Markdown`** thin aliases for `Text(type_=...)`.
131
131
  - **`block_kit_builder_url(payload)`** — turn any block, message, or view into a [Block Kit Builder](https://app.slack.com/block-kit-builder) URL for browser preview.
132
132
  - **`Workflow.from_url(url, **params)`** — one-line workflow construction.
133
- - **`Block.from_dict(data)`** + per-class `from_dict` parsers — round-trip incoming Slack JSON back into `slackblocks` objects.
133
+ - **`Block.from_dict(data)`** + per-class `from_dict` parsers — parse the supported subset of incoming Slack JSON into `slackblocks` objects.
134
134
  - **Typed exception subclasses** of `InvalidUsageError` (`LengthError`, `RangeError`, `TypeMismatchError`, `MutualExclusivityError`, `MissingRequiredError`) — existing `except InvalidUsageError` blocks continue to work.
135
135
  - **Tighter type signatures**: `Literal` narrowing on `Button.style`, `ColumnSettings.align`, `ConversationFilter.include`; `@overload` on `Text.to_text` so the return type narrows on `allow_none`.
136
136
  - **Modern annotation syntax** (`list[X]`, `X | Y`) throughout.
@@ -144,7 +144,7 @@ Existing 1.x code continues to work unchanged; see the [Migration Guide](https:/
144
144
  - [Compatibility](https://nicklambourne.github.io/slackblocks/usage/compatibility) — which Python versions each release line supports.
145
145
  - [Using Blocks](https://nicklambourne.github.io/slackblocks/usage/using_blocks) — every block type with code, JSON, and screenshots.
146
146
  - [Sending Messages](https://nicklambourne.github.io/slackblocks/usage/sending_messages)
147
- - [Cookbook](https://nicklambourne.github.io/slackblocks/usage/cookbook) — end-to-end recipes for build notifications, approval requests, modals, and more.
147
+ - [Cookbook](https://nicklambourne.github.io/slackblocks/usage/recipes) — end-to-end recipes for build notifications, approval requests, modals, and more.
148
148
  - [Migrating from 1.x](https://nicklambourne.github.io/slackblocks/usage/migration) — upgrade guide for `1.x` users.
149
149
  - [Troubleshooting & FAQ](https://nicklambourne.github.io/slackblocks/usage/troubleshooting)
150
150
  - [Changelog](https://github.com/nicklambourne/slackblocks/blob/master/python/CHANGELOG.md)
@@ -86,14 +86,14 @@ The `**` operator unpacks `slackblocks` `Message` objects directly into the SDK
86
86
 
87
87
  | Surface | Status |
88
88
  |---------------------|:------:|
89
- | Blocks | ✅ All current block types, including Alert, Card, Carousel, Container, Context Actions, Data Table, Data Visualization, Plan, and Task Card |
89
+ | Blocks | ✅ All current block types, including Alert, Card, Carousel, Container, Layout (3.2.0+), Context Actions, Data Table, Data Visualization, Plan, and Task Card |
90
90
  | Elements | ✅ Buttons, all select menus, date/time pickers, checkboxes, radio groups, all input types, overflow menus, workflow buttons |
91
91
  | Composition Objects | ✅ Text (+ `PlainText` / `Markdown` aliases), Option, Confirm, Conversation/Dispatch filters, Workflow, Trigger |
92
92
  | Rich Text | ✅ Sections, lists, quotes, code blocks, inline links/users/channels/emoji |
93
93
  | Modals & Home Tabs | ✅ Full views API |
94
94
  | Messages | ✅ `chat.postMessage`, webhook messages, slash-command/interaction responses, threaded replies, ephemeral messages |
95
95
  | Attachments | ⚠️ Supported but [deprecated by Slack](https://api.slack.com/reference/messaging/attachments) |
96
- | Round-tripping | ✅ `Block.from_dict(data)` and per-class `from_dict` for parsing incoming Slack JSON back into objects |
96
+ | Round-tripping | ⚠️ Partial `Block.from_dict(data)` parsing; composite blocks including Layout raise `NotImplementedError`. See the [support table](https://nicklambourne.github.io/slackblocks/usage/migration#round-tripping-incoming-slack-json-with-from_dict) |
97
97
 
98
98
  ## What's new in 2.0
99
99
 
@@ -103,7 +103,7 @@ The `**` operator unpacks `slackblocks` `Message` objects directly into the SDK
103
103
  - **`PlainText` and `Markdown`** thin aliases for `Text(type_=...)`.
104
104
  - **`block_kit_builder_url(payload)`** — turn any block, message, or view into a [Block Kit Builder](https://app.slack.com/block-kit-builder) URL for browser preview.
105
105
  - **`Workflow.from_url(url, **params)`** — one-line workflow construction.
106
- - **`Block.from_dict(data)`** + per-class `from_dict` parsers — round-trip incoming Slack JSON back into `slackblocks` objects.
106
+ - **`Block.from_dict(data)`** + per-class `from_dict` parsers — parse the supported subset of incoming Slack JSON into `slackblocks` objects.
107
107
  - **Typed exception subclasses** of `InvalidUsageError` (`LengthError`, `RangeError`, `TypeMismatchError`, `MutualExclusivityError`, `MissingRequiredError`) — existing `except InvalidUsageError` blocks continue to work.
108
108
  - **Tighter type signatures**: `Literal` narrowing on `Button.style`, `ColumnSettings.align`, `ConversationFilter.include`; `@overload` on `Text.to_text` so the return type narrows on `allow_none`.
109
109
  - **Modern annotation syntax** (`list[X]`, `X | Y`) throughout.
@@ -117,7 +117,7 @@ Existing 1.x code continues to work unchanged; see the [Migration Guide](https:/
117
117
  - [Compatibility](https://nicklambourne.github.io/slackblocks/usage/compatibility) — which Python versions each release line supports.
118
118
  - [Using Blocks](https://nicklambourne.github.io/slackblocks/usage/using_blocks) — every block type with code, JSON, and screenshots.
119
119
  - [Sending Messages](https://nicklambourne.github.io/slackblocks/usage/sending_messages)
120
- - [Cookbook](https://nicklambourne.github.io/slackblocks/usage/cookbook) — end-to-end recipes for build notifications, approval requests, modals, and more.
120
+ - [Cookbook](https://nicklambourne.github.io/slackblocks/usage/recipes) — end-to-end recipes for build notifications, approval requests, modals, and more.
121
121
  - [Migrating from 1.x](https://nicklambourne.github.io/slackblocks/usage/migration) — upgrade guide for `1.x` users.
122
122
  - [Troubleshooting & FAQ](https://nicklambourne.github.io/slackblocks/usage/troubleshooting)
123
123
  - [Changelog](https://github.com/nicklambourne/slackblocks/blob/master/python/CHANGELOG.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slackblocks"
3
- version = "3.0.0"
3
+ version = "3.2.0"
4
4
  description = "Python wrapper for the Slack Blocks API"
5
5
  authors = [
6
6
  { name = "Nicholas Lambourne", email = "dev@ndl.im" },
@@ -19,6 +19,14 @@ from .blocks import (
19
19
  HeaderBlock,
20
20
  ImageBlock,
21
21
  InputBlock,
22
+ LayoutAlignment,
23
+ LayoutBlock,
24
+ LayoutChild,
25
+ LayoutFallback,
26
+ LayoutGridLines,
27
+ LayoutItem,
28
+ LayoutRow,
29
+ LayoutWidth,
22
30
  MarkdownBlock,
23
31
  PlanBlock,
24
32
  RichTextBlock,
@@ -123,7 +131,7 @@ name = "slackblocks"
123
131
 
124
132
  # The version of the shared conformance spec (spec/SPEC.md) this
125
133
  # implementation conforms to.
126
- SPEC_VERSION: str = "1.4.0"
134
+ SPEC_VERSION: str = "1.5.0"
127
135
 
128
136
  # Literal exports are visible to strict external type checkers.
129
137
  __all__ = [
@@ -182,6 +190,14 @@ __all__ = [
182
190
  "LengthError",
183
191
  "LineChart",
184
192
  "Markdown",
193
+ "LayoutBlock",
194
+ "LayoutRow",
195
+ "LayoutItem",
196
+ "LayoutWidth",
197
+ "LayoutAlignment",
198
+ "LayoutGridLines",
199
+ "LayoutChild",
200
+ "LayoutFallback",
185
201
  "MarkdownBlock",
186
202
  "Message",
187
203
  "MessageResponse",
@@ -0,0 +1,56 @@
1
+ """Modeled composite-block traversal. Opaque payload metadata is never visited."""
2
+
3
+ from typing import Any
4
+
5
+ from .errors import InvalidUsageError
6
+
7
+
8
+ def child_blocks(block: dict[str, Any]) -> list[dict[str, Any]]:
9
+ if block.get("type") == "container":
10
+ return block.get("child_blocks", [])
11
+ if block.get("type") == "layout":
12
+ return [item["block"] for row in block.get("rows", []) for item in row["items"]]
13
+ return []
14
+
15
+
16
+ def validate_composite_depth(
17
+ block: dict[str, Any], depth: int = 0, has_layout: bool = False
18
+ ) -> None:
19
+ kind = block.get("type")
20
+ has_layout = has_layout or kind == "layout"
21
+ if kind in ("layout", "container"):
22
+ depth += 1
23
+ if has_layout and depth > 2:
24
+ raise InvalidUsageError("Layout and container nesting cannot exceed two composite levels.")
25
+ for nested in child_blocks(block):
26
+ validate_composite_depth(nested, depth, has_layout)
27
+
28
+
29
+ def descendants_with_paths(
30
+ blocks: list[dict[str, Any]], path: str = "blocks"
31
+ ) -> list[tuple[str, dict[str, Any]]]:
32
+ """Walk modeled children and fallback while retaining their wire locations."""
33
+ result = []
34
+
35
+ def visit(block: dict[str, Any], block_path: str) -> None:
36
+ result.append((block_path, block))
37
+ if block.get("type") == "container":
38
+ for index, nested in enumerate(block.get("child_blocks", [])):
39
+ visit(nested, f"{block_path}.child_blocks[{index}]")
40
+ elif block.get("type") == "layout":
41
+ for row_index, row in enumerate(block.get("rows", [])):
42
+ for item_index, item in enumerate(row["items"]):
43
+ visit(
44
+ item["block"], f"{block_path}.rows[{row_index}].items[{item_index}].block"
45
+ )
46
+ for index, nested in enumerate(block.get("fallback", [])):
47
+ visit(nested, f"{block_path}.fallback[{index}]")
48
+
49
+ for index, block in enumerate(blocks):
50
+ visit(block, f"{path}[{index}]")
51
+ return result
52
+
53
+
54
+ def descendants(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
55
+ """Walk actual children and fallback content, without interpreting application data."""
56
+ return [block for _, block in descendants_with_paths(blocks)]
@@ -295,3 +295,15 @@ VIEW_TITLE_MAX_LENGTH = 24
295
295
  VIEW_CLOSE_MAX_LENGTH = 24
296
296
  # view.submit.max_length
297
297
  VIEW_SUBMIT_MAX_LENGTH = 24
298
+ # layout.label.min_length
299
+ LAYOUT_LABEL_MIN_LENGTH = 1
300
+ # layout.label.max_length
301
+ LAYOUT_LABEL_MAX_LENGTH = 150
302
+ # layout.rows.min_items
303
+ LAYOUT_ROWS_MIN_ITEMS = 1
304
+ # layout.rows.max_items
305
+ LAYOUT_ROWS_MAX_ITEMS = 5
306
+ # layout.items.min_items
307
+ LAYOUT_ITEMS_MIN_ITEMS = 1
308
+ # layout.items.max_items
309
+ LAYOUT_ITEMS_MAX_ITEMS = 3
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any, Literal
6
6
 
7
7
  from ._core import resolve
8
+ from ._layout import descendants_with_paths
8
9
  from .errors import TypeMismatchError
9
10
 
10
11
  BlockSurface = Literal["home", "message", "modal"]
@@ -25,6 +26,7 @@ _BLOCK_TYPES_BY_SURFACE: dict[BlockSurface, frozenset[str]] = {
25
26
  "header",
26
27
  "image",
27
28
  "input",
29
+ "layout",
28
30
  "markdown",
29
31
  "plan",
30
32
  "rich_text",
@@ -44,6 +46,7 @@ _BLOCK_TYPES_BY_SURFACE: dict[BlockSurface, frozenset[str]] = {
44
46
  "header",
45
47
  "image",
46
48
  "input",
49
+ "layout",
47
50
  "rich_text",
48
51
  "section",
49
52
  "video",
@@ -62,6 +65,7 @@ _BLOCK_TYPES_BY_SURFACE: dict[BlockSurface, frozenset[str]] = {
62
65
  "header",
63
66
  "image",
64
67
  "input",
68
+ "layout",
65
69
  "rich_text",
66
70
  "section",
67
71
  "table",
@@ -83,12 +87,10 @@ def block_type(block: Any) -> str | None:
83
87
  def validate_surface_blocks(blocks: list[Any], surface: BlockSurface) -> None:
84
88
  """Reject block types that Slack does not support on ``surface``."""
85
89
  allowed = _BLOCK_TYPES_BY_SURFACE[surface]
86
- for index, block in enumerate(blocks):
90
+ for path, block in descendants_with_paths([resolve(block) for block in blocks]):
87
91
  type_ = block_type(block)
88
92
  if type_ not in allowed:
89
- raise TypeMismatchError(
90
- f"blocks[{index}] type {type_!r} is not supported on {surface} surfaces"
91
- )
93
+ raise TypeMismatchError(f"{path} type {type_!r} is not supported on {surface} surfaces")
92
94
  validate_standalone_task_cards(blocks)
93
95
 
94
96
 
@@ -46,6 +46,12 @@ from slackblocks._limits import (
46
46
  IMAGE_TITLE_MAX_LENGTH,
47
47
  INPUT_HINT_MAX_LENGTH,
48
48
  INPUT_LABEL_MAX_LENGTH,
49
+ LAYOUT_ITEMS_MAX_ITEMS,
50
+ LAYOUT_ITEMS_MIN_ITEMS,
51
+ LAYOUT_LABEL_MAX_LENGTH,
52
+ LAYOUT_LABEL_MIN_LENGTH,
53
+ LAYOUT_ROWS_MAX_ITEMS,
54
+ LAYOUT_ROWS_MIN_ITEMS,
49
55
  MARKDOWN_TEXT_MAX_LENGTH,
50
56
  PLAN_TASKS_MAX_ITEMS,
51
57
  SECTION_FIELDS_ITEM_MAX_LENGTH,
@@ -184,6 +190,7 @@ class BlockType(Enum):
184
190
  HEADER = "header"
185
191
  IMAGE = "image"
186
192
  INPUT = "input"
193
+ LAYOUT = "layout"
187
194
  MARKDOWN = "markdown"
188
195
  PLAN = "plan"
189
196
  RICH_TEXT = "rich_text"
@@ -238,7 +245,7 @@ class Block(RenderableMixin, ABC):
238
245
  containing nested elements, cards, tasks, charts, or
239
246
  rich text -- ``ActionsBlock``, ``InputBlock``,
240
247
  ``TableBlock``, ``CardBlock``, ``CarouselBlock``,
241
- ``ContainerBlock``, ``ContextActionsBlock``,
248
+ ``ContainerBlock``, ``LayoutBlock``, ``ContextActionsBlock``,
242
249
  ``DataTableBlock``, ``DataVisualizationBlock``,
243
250
  ``TaskCardBlock``, and ``PlanBlock`` -- see Phase 7.4b
244
251
  and 7.4c).
@@ -279,6 +286,7 @@ def _block_from_dict_registry() -> dict[str, Any]:
279
286
  BlockType.HEADER.value: HeaderBlock.from_dict,
280
287
  BlockType.IMAGE.value: ImageBlock.from_dict,
281
288
  BlockType.INPUT.value: InputBlock.from_dict,
289
+ BlockType.LAYOUT.value: LayoutBlock.from_dict,
282
290
  BlockType.MARKDOWN.value: MarkdownBlock.from_dict,
283
291
  BlockType.PLAN.value: PlanBlock.from_dict,
284
292
  BlockType.RICH_TEXT.value: RichTextBlock.from_dict,
@@ -1204,7 +1212,7 @@ class ContainerBlock(Block):
1204
1212
  child_blocks: a list of between 1 and 10 blocks to display inside
1205
1213
  the container. Supported child block types are actions, context,
1206
1214
  divider, file, header, image, input, rich text, section, table,
1207
- and video blocks.
1215
+ layout, and video blocks.
1208
1216
  title: the container's title (plaintext only; max 150 chars).
1209
1217
  rich_text_title: a `RichTextBlock` used as the container's title in
1210
1218
  place of `title`.
@@ -1233,6 +1241,7 @@ class ContainerBlock(Block):
1233
1241
  BlockType.HEADER,
1234
1242
  BlockType.IMAGE,
1235
1243
  BlockType.INPUT,
1244
+ BlockType.LAYOUT,
1236
1245
  BlockType.RICH_TEXT,
1237
1246
  BlockType.SECTION,
1238
1247
  BlockType.TABLE,
@@ -1285,6 +1294,9 @@ class ContainerBlock(Block):
1285
1294
  self.is_collapsible = is_collapsible
1286
1295
  self.default_collapsed = default_collapsed
1287
1296
  self.has_header_divider = has_header_divider
1297
+ from ._layout import validate_composite_depth
1298
+
1299
+ validate_composite_depth(self._resolve())
1288
1300
 
1289
1301
  def _resolve(self) -> dict[str, Any]:
1290
1302
  return resolve(
@@ -1601,3 +1613,194 @@ class PlanBlock(Block):
1601
1613
  raise NotImplementedError(
1602
1614
  "PlanBlock.from_dict requires nested task parsing, which is not implemented."
1603
1615
  )
1616
+
1617
+
1618
+ LayoutWidth: TypeAlias = Literal["narrow", "standard", "wide"]
1619
+ LayoutAlignment: TypeAlias = Literal["start", "center", "end"]
1620
+ LayoutGridLines: TypeAlias = Literal["none", "rows", "outer"]
1621
+ LayoutChild: TypeAlias = (
1622
+ ActionsBlock
1623
+ | AlertBlock
1624
+ | CardBlock
1625
+ | CarouselBlock
1626
+ | ContainerBlock
1627
+ | ContextBlock
1628
+ | DataTableBlock
1629
+ | DataVisualizationBlock
1630
+ | DividerBlock
1631
+ | FileBlock
1632
+ | HeaderBlock
1633
+ | ImageBlock
1634
+ | InputBlock
1635
+ | RichTextBlock
1636
+ | SectionBlock
1637
+ | TableBlock
1638
+ | VideoBlock
1639
+ )
1640
+ LayoutFallback: TypeAlias = (
1641
+ ContextBlock
1642
+ | DividerBlock
1643
+ | HeaderBlock
1644
+ | ImageBlock
1645
+ | RichTextBlock
1646
+ | SectionBlock
1647
+ | TableBlock
1648
+ | VideoBlock
1649
+ )
1650
+
1651
+
1652
+ class LayoutItem(RenderableMixin):
1653
+ """One layout cell containing a supported block.
1654
+
1655
+ Widths are relative weights: narrow 1, standard 2, wide 3. Alignment is
1656
+ direction-aware; omit it for fill behavior. The child must also be allowed
1657
+ on the enclosing message, modal, or Home surface.
1658
+
1659
+ See: <https://docs.slack.dev/reference/block-kit/blocks/layout-block>.
1660
+ """
1661
+
1662
+ _CHILD_TYPES = frozenset(
1663
+ {
1664
+ "actions",
1665
+ "alert",
1666
+ "card",
1667
+ "carousel",
1668
+ "container",
1669
+ "context",
1670
+ "data_table",
1671
+ "data_visualization",
1672
+ "divider",
1673
+ "file",
1674
+ "header",
1675
+ "image",
1676
+ "input",
1677
+ "rich_text",
1678
+ "section",
1679
+ "table",
1680
+ "video",
1681
+ }
1682
+ )
1683
+
1684
+ def __init__(
1685
+ self,
1686
+ block: LayoutChild,
1687
+ width: LayoutWidth | None = None,
1688
+ horizontal_alignment: LayoutAlignment | None = None,
1689
+ ) -> None:
1690
+ self.block = validate_type(block, Block, "block")
1691
+ if self.block.type.value not in self._CHILD_TYPES:
1692
+ raise TypeMismatchError("Unsupported layout child block.")
1693
+ if width not in (None, "narrow", "standard", "wide"):
1694
+ raise TypeMismatchError("Unknown layout item width.")
1695
+ if horizontal_alignment not in (None, "start", "center", "end"):
1696
+ raise TypeMismatchError("Unknown layout item alignment.")
1697
+ self.width = width
1698
+ self.horizontal_alignment = horizontal_alignment
1699
+
1700
+ def _resolve(self) -> dict[str, Any]:
1701
+ return resolve(
1702
+ {
1703
+ "block": self.block,
1704
+ "width": self.width,
1705
+ "horizontal_alignment": self.horizontal_alignment,
1706
+ }
1707
+ )
1708
+
1709
+
1710
+ class LayoutRow(RenderableMixin):
1711
+ """One to three items in source order, with at most one wide-content block.
1712
+
1713
+ A carousel, data table, or data visualization cannot share a row with
1714
+ another such block. A data visualization requires more than half the total
1715
+ row weight. Alignment belongs to each item, never to the row itself.
1716
+ """
1717
+
1718
+ def __init__(self, items: list[LayoutItem]) -> None:
1719
+ self.items: list[LayoutItem] = coerce_to_list_nonnull(
1720
+ items, LayoutItem, min_size=LAYOUT_ITEMS_MIN_ITEMS, max_size=LAYOUT_ITEMS_MAX_ITEMS
1721
+ )
1722
+ wide = [
1723
+ item
1724
+ for item in self.items
1725
+ if item.block.type
1726
+ in (BlockType.CAROUSEL, BlockType.DATA_TABLE, BlockType.DATA_VISUALIZATION)
1727
+ ]
1728
+ if len(wide) > 1:
1729
+ raise InvalidUsageError("A layout row permits at most one wide-content block.")
1730
+ weights = {None: 2, "narrow": 1, "standard": 2, "wide": 3}
1731
+ total = sum(weights[item.width] for item in self.items)
1732
+ for item in self.items:
1733
+ if item.block.type == BlockType.DATA_VISUALIZATION and weights[item.width] * 2 <= total:
1734
+ raise InvalidUsageError(
1735
+ "A data visualization requires more than half the row width."
1736
+ )
1737
+
1738
+ def _resolve(self) -> dict[str, Any]:
1739
+ return resolve({"items": self.items})
1740
+
1741
+
1742
+ class LayoutBlock(Block):
1743
+ """A visual grid of one to five rows on messages, modals, and App Home.
1744
+
1745
+ The label is required (1–150 Unicode characters). Slack hides it by default;
1746
+ a visible label is bold text without heading semantics. Rows retain source
1747
+ order when wrapping, and stack on mobile. Supply stable fallback blocks for
1748
+ older clients: Slack never synthesizes them. Omitted optional fields remain
1749
+ omitted on input; Slack normalizes an omitted fallback to an empty array.
1750
+
1751
+ See: <https://docs.slack.dev/reference/block-kit/blocks/layout-block>.
1752
+ """
1753
+
1754
+ _FALLBACK_TYPES = frozenset(
1755
+ {"context", "divider", "header", "image", "rich_text", "section", "table", "video"}
1756
+ )
1757
+
1758
+ def __init__(
1759
+ self,
1760
+ label: str,
1761
+ rows: list[LayoutRow],
1762
+ *,
1763
+ is_label_visible: bool | None = None,
1764
+ grid_lines: LayoutGridLines | None = None,
1765
+ fallback: list[LayoutFallback] | None = None,
1766
+ block_id: str | None = None,
1767
+ ) -> None:
1768
+ super().__init__(BlockType.LAYOUT, block_id)
1769
+ self.label = validate_string_nonnull(
1770
+ label, "label", min_length=LAYOUT_LABEL_MIN_LENGTH, max_length=LAYOUT_LABEL_MAX_LENGTH
1771
+ )
1772
+ self.rows: list[LayoutRow] = coerce_to_list_nonnull(
1773
+ rows, LayoutRow, min_size=LAYOUT_ROWS_MIN_ITEMS, max_size=LAYOUT_ROWS_MAX_ITEMS
1774
+ )
1775
+ self.is_label_visible = validate_type(
1776
+ is_label_visible, bool, "is_label_visible", allow_none=True
1777
+ )
1778
+ if grid_lines not in (None, "none", "rows", "outer"):
1779
+ raise TypeMismatchError("Unknown layout grid lines.")
1780
+ self.grid_lines = grid_lines
1781
+ self.fallback: list[Block] | None = None
1782
+ if fallback is not None:
1783
+ self.fallback = coerce_to_list_nonnull(list[Block](fallback), Block)
1784
+ if any(b.type.value not in self._FALLBACK_TYPES for b in self.fallback):
1785
+ raise TypeMismatchError("Unsupported layout fallback block.")
1786
+ from ._layout import validate_composite_depth
1787
+
1788
+ validate_composite_depth(self._resolve())
1789
+
1790
+ def _resolve(self) -> dict[str, Any]:
1791
+ return resolve(
1792
+ {
1793
+ **self._attributes(),
1794
+ "label": self.label,
1795
+ "rows": self.rows,
1796
+ "is_label_visible": self.is_label_visible,
1797
+ "grid_lines": self.grid_lines,
1798
+ "fallback": self.fallback,
1799
+ }
1800
+ )
1801
+
1802
+ @classmethod
1803
+ def from_dict(cls, data: dict[str, Any]) -> LayoutBlock:
1804
+ raise NotImplementedError(
1805
+ "LayoutBlock.from_dict requires nested block parsing, which is not implemented."
1806
+ )
@@ -13,6 +13,7 @@ from json import dumps
13
13
  from typing import Any
14
14
 
15
15
  from slackblocks._core import resolve
16
+ from slackblocks._layout import descendants
16
17
  from slackblocks._limits import (
17
18
  DATA_TABLE_TOTAL_CONTENT_MAX_LENGTH,
18
19
  MARKDOWN_TOTAL_TEXT_MAX_LENGTH,
@@ -27,10 +28,6 @@ from slackblocks.utils import coerce_to_list, validate_string_nonnull
27
28
  from .attachments import Attachment
28
29
  from .blocks import (
29
30
  Block,
30
- ContainerBlock,
31
- DataTableBlock,
32
- DataVisualizationBlock,
33
- MarkdownBlock,
34
31
  _text_character_count,
35
32
  )
36
33
  from .errors import LengthError, TypeMismatchError
@@ -62,25 +59,25 @@ def _validate_message_totals(
62
59
  """
63
60
  all_blocks = [*(blocks or []), *(block for att in attachments or [] for block in att.blocks)]
64
61
 
65
- def chart_count(block: Block) -> int:
66
- if isinstance(block, DataVisualizationBlock):
67
- return 1
68
- if isinstance(block, ContainerBlock):
69
- return sum(chart_count(child) for child in block.child_blocks)
70
- return 0
62
+ rendered_blocks = descendants([resolve(block) for block in all_blocks])
71
63
 
72
- if sum(chart_count(block) for block in all_blocks) > MESSAGE_DATA_VISUALIZATIONS_MAX_ITEMS:
64
+ if (
65
+ sum(block.get("type") == "data_visualization" for block in rendered_blocks)
66
+ > MESSAGE_DATA_VISUALIZATIONS_MAX_ITEMS
67
+ ):
73
68
  raise LengthError("A message may contain at most two data visualization blocks.")
74
- markdown = sum(len(block.text) for block in all_blocks if isinstance(block, MarkdownBlock))
69
+ markdown = sum(
70
+ len(block["text"]) for block in rendered_blocks if block.get("type") == "markdown"
71
+ )
75
72
  if markdown > MARKDOWN_TOTAL_TEXT_MAX_LENGTH:
76
73
  raise LengthError(
77
74
  f"Markdown block text ({markdown} characters) exceeds the message-wide limit of "
78
75
  f"{MARKDOWN_TOTAL_TEXT_MAX_LENGTH} characters."
79
76
  )
80
77
  data_table = sum(
81
- _text_character_count(resolve(block.rows))
82
- for block in all_blocks
83
- if isinstance(block, DataTableBlock)
78
+ _text_character_count(block["rows"])
79
+ for block in rendered_blocks
80
+ if block.get("type") == "data_table"
84
81
  )
85
82
  if data_table > DATA_TABLE_TOTAL_CONTENT_MAX_LENGTH:
86
83
  raise LengthError(
@@ -11,6 +11,7 @@ from json import dumps
11
11
  from typing import Any
12
12
 
13
13
  from slackblocks._core import resolve
14
+ from slackblocks._layout import descendants
14
15
  from slackblocks._limits import (
15
16
  VIEW_BLOCKS_MAX_ITEMS,
16
17
  VIEW_CALLBACK_ID_MAX_LENGTH,
@@ -170,7 +171,9 @@ class ModalView(View):
170
171
  self.submit = Text.to_text(
171
172
  submit, force_plaintext=True, max_length=VIEW_SUBMIT_MAX_LENGTH, allow_none=True
172
173
  )
173
- if self.submit is None and any(block_type(block) == "input" for block in self.blocks):
174
+ if self.submit is None and any(
175
+ block_type(block) == "input" for block in descendants([resolve(b) for b in self.blocks])
176
+ ):
174
177
  raise MissingRequiredError("submit is required when a modal contains an input block")
175
178
  self.clear_on_close = clear_on_close
176
179
  self.notify_on_close = notify_on_close
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.10"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.15'",
@@ -300,11 +300,11 @@ wheels = [
300
300
 
301
301
  [[package]]
302
302
  name = "certifi"
303
- version = "2026.4.22"
303
+ version = "2026.10.18"
304
304
  source = { registry = "https://pypi.org/simple" }
305
- sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
305
+ sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" }
306
306
  wheels = [
307
- { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
307
+ { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" },
308
308
  ]
309
309
 
310
310
  [[package]]
@@ -1695,7 +1695,7 @@ wheels = [
1695
1695
 
1696
1696
  [[package]]
1697
1697
  name = "slackblocks"
1698
- version = "3.0.0"
1698
+ version = "3.2.0"
1699
1699
  source = { editable = "." }
1700
1700
 
1701
1701
  [package.dev-dependencies]
File without changes