Skip to content

Commit 4058e59

Browse files
authored
error if no children allowed (#3758)
* error if no children allowed * changelog * changelog * remove comment * quote RHS * annotations * attempt to fix 3.7 * restore experiment
1 parent fb2a0fe commit 4058e59

File tree

9 files changed

+66
-4
lines changed

9 files changed

+66
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1818

1919
- Added experimental Canvas class https://github.com/Textualize/textual/pull/3669/
2020
- Added `keyline` rule https://github.com/Textualize/textual/pull/3669/
21+
- Widgets can now have an ALLOW_CHILDREN (bool) classvar to disallow adding children to a widget https://github.com/Textualize/textual/pull/3758
2122

2223
### Changed
2324

src/textual/_compose.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,33 @@ def compose(node: App | Widget) -> list[Widget]:
1616
Returns:
1717
A list of widgets.
1818
"""
19+
_rich_traceback_omit = True
1920
app = node.app
2021
nodes: list[Widget] = []
2122
compose_stack: list[Widget] = []
2223
composed: list[Widget] = []
2324
app._compose_stacks.append(compose_stack)
2425
app._composed.append(composed)
26+
iter_compose = iter(node.compose())
2527
try:
26-
for child in node.compose():
28+
while True:
29+
try:
30+
child = next(iter_compose)
31+
except StopIteration:
32+
break
2733
if composed:
2834
nodes.extend(composed)
2935
composed.clear()
3036
if compose_stack:
31-
compose_stack[-1].compose_add_child(child)
37+
try:
38+
compose_stack[-1].compose_add_child(child)
39+
except Exception as error:
40+
if hasattr(iter_compose, "throw"):
41+
# So the error is raised inside the generator
42+
# This will generate a more sensible traceback for the dev
43+
iter_compose.throw(error) # type: ignore
44+
else:
45+
raise
3246
else:
3347
nodes.append(child)
3448
if composed:

src/textual/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,7 @@ async def take_screenshot() -> None:
23052305
)
23062306

23072307
async def _on_compose(self) -> None:
2308+
_rich_traceback_omit = True
23082309
try:
23092310
widgets = [*self.screen._nodes, *compose(self)]
23102311
except TypeError as error:

src/textual/scroll_view.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class ScrollView(ScrollableContainer):
1818
on the compositor to render children).
1919
"""
2020

21+
ALLOW_CHILDREN = False
22+
2123
DEFAULT_CSS = """
2224
ScrollView {
2325
overflow-y: auto;

src/textual/widget.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@
8787
}
8888

8989

90+
class NotAContainer(Exception):
91+
"""Exception raised if you attempt to add a child to a widget which doesn't permit child nodes."""
92+
93+
9094
_NULL_STYLE = Style()
9195

9296

@@ -264,6 +268,9 @@ class Widget(DOMNode):
264268
BORDER_SUBTITLE: ClassVar[str] = ""
265269
"""Initial value for border_subtitle attribute."""
266270

271+
ALLOW_CHILDREN: ClassVar[bool] = True
272+
"""Set to `False` to prevent adding children to this widget."""
273+
267274
can_focus: bool = False
268275
"""Widget may receive focus."""
269276
can_focus_children: bool = True
@@ -488,6 +495,21 @@ def tooltip(self, tooltip: RenderableType | None):
488495
except NoScreen:
489496
pass
490497

498+
def compose_add_child(self, widget: Widget) -> None:
499+
"""Add a node to children.
500+
501+
This is used by the compose process when it adds children.
502+
There is no need to use it directly, but you may want to override it in a subclass
503+
if you want children to be attached to a different node.
504+
505+
Args:
506+
widget: A Widget to add.
507+
"""
508+
_rich_traceback_omit = True
509+
if not self.ALLOW_CHILDREN:
510+
raise NotAContainer(f"Can't add children to {type(widget)} widgets")
511+
self._nodes._append(widget)
512+
491513
def __enter__(self) -> Self:
492514
"""Use as context manager when composing."""
493515
self.app._compose_stacks[-1].append(self)

src/textual/widgets/_button.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ class Button(Widget, can_focus=True):
153153

154154
BINDINGS = [Binding("enter", "press", "Press Button", show=False)]
155155

156+
ALLOW_CHILDREN = False
157+
156158
label: reactive[TextType] = reactive[TextType]("")
157159
"""The text label that appears within the button."""
158160

src/textual/widgets/_static.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class Static(Widget, inherit_bindings=False):
4444
}
4545
"""
4646

47+
ALLOW_CHILDREN = False
48+
4749
_renderable: RenderableType
4850

4951
def __init__(

src/textual/widgets/_toggle_button.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class ToggleButton(Static, can_focus=True):
5151
| `toggle--label` | Targets the text label of the toggle button. |
5252
"""
5353

54+
ALLOW_CHILDREN = False
55+
5456
DEFAULT_CSS = """
5557
ToggleButton {
5658
width: auto;

tests/test_widget.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from textual.css.query import NoMatches
1010
from textual.geometry import Offset, Size
1111
from textual.message import Message
12-
from textual.widget import MountError, PseudoClasses, Widget
13-
from textual.widgets import Label, LoadingIndicator
12+
from textual.widget import MountError, NotAContainer, PseudoClasses, Widget
13+
from textual.widgets import Label, LoadingIndicator, Static
1414

1515

1616
@pytest.mark.parametrize(
@@ -394,3 +394,19 @@ class TestWidgetIsMountedApp(App):
394394
assert widget.is_mounted is False
395395
await pilot.app.mount(widget)
396396
assert widget.is_mounted is True
397+
398+
399+
async def test_not_allow_children():
400+
"""Regression test for https://github.com/Textualize/textual/pull/3758"""
401+
402+
class TestAppExpectFail(App):
403+
def compose(self) -> ComposeResult:
404+
# Statics don't have children, so this should error
405+
with Static():
406+
yield Label("foo")
407+
408+
app = TestAppExpectFail()
409+
410+
with pytest.raises(NotAContainer):
411+
async with app.run_test():
412+
pass

0 commit comments

Comments
 (0)