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.
- {slackblocks-3.0.0 → slackblocks-3.2.0}/.gitignore +5 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/CHANGELOG.md +11 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/PKG-INFO +5 -5
- {slackblocks-3.0.0 → slackblocks-3.2.0}/README.md +4 -4
- {slackblocks-3.0.0 → slackblocks-3.2.0}/pyproject.toml +1 -1
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/__init__.py +17 -1
- slackblocks-3.2.0/slackblocks/_layout.py +56 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_limits.py +12 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_surfaces.py +6 -4
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/blocks.py +205 -2
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/messages.py +12 -15
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/views.py +4 -1
- {slackblocks-3.0.0 → slackblocks-3.2.0}/uv.lock +5 -5
- {slackblocks-3.0.0 → slackblocks-3.2.0}/LICENSE +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/LICENSE.BSD-3-Clause +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/conformance/skiplist.txt +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/generator/generate_limits.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/_core.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/attachments.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/builder.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/elements.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/errors.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/modals.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/objects.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/py.typed +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/__init__.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/elements.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/rich_text/objects.py +0 -0
- {slackblocks-3.0.0 → slackblocks-3.2.0}/slackblocks/utils.py +0 -0
|
@@ -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.
|
|
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 |
|
|
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 —
|
|
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/
|
|
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 |
|
|
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 —
|
|
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/
|
|
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)
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
82
|
-
for block in
|
|
83
|
-
if
|
|
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(
|
|
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 =
|
|
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.
|
|
303
|
+
version = "2026.10.18"
|
|
304
304
|
source = { registry = "https://pypi.org/simple" }
|
|
305
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
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/
|
|
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.
|
|
1698
|
+
version = "3.2.0"
|
|
1699
1699
|
source = { editable = "." }
|
|
1700
1700
|
|
|
1701
1701
|
[package.dev-dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|