Skip to content

Commit

Permalink
error if no children allowed (#3758)
Browse files Browse the repository at this point in the history
* error if no children allowed

* changelog

* changelog

* remove comment

* quote RHS

* annotations

* attempt to fix 3.7

* restore experiment
  • Loading branch information
willmcgugan authored Nov 27, 2023
1 parent fb2a0fe commit 4058e59
Show file tree
Hide file tree
Showing 9 changed files with 66 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

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

### Changed

Expand Down
18 changes: 16 additions & 2 deletions src/textual/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,33 @@ def compose(node: App | Widget) -> list[Widget]:
Returns:
A list of widgets.
"""
_rich_traceback_omit = True
app = node.app
nodes: list[Widget] = []
compose_stack: list[Widget] = []
composed: list[Widget] = []
app._compose_stacks.append(compose_stack)
app._composed.append(composed)
iter_compose = iter(node.compose())
try:
for child in node.compose():
while True:
try:
child = next(iter_compose)
except StopIteration:
break
if composed:
nodes.extend(composed)
composed.clear()
if compose_stack:
compose_stack[-1].compose_add_child(child)
try:
compose_stack[-1].compose_add_child(child)
except Exception as error:
if hasattr(iter_compose, "throw"):
# So the error is raised inside the generator
# This will generate a more sensible traceback for the dev
iter_compose.throw(error) # type: ignore
else:
raise
else:
nodes.append(child)
if composed:
Expand Down
1 change: 1 addition & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2305,6 +2305,7 @@ async def take_screenshot() -> None:
)

async def _on_compose(self) -> None:
_rich_traceback_omit = True
try:
widgets = [*self.screen._nodes, *compose(self)]
except TypeError as error:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class ScrollView(ScrollableContainer):
on the compositor to render children).
"""

ALLOW_CHILDREN = False

DEFAULT_CSS = """
ScrollView {
overflow-y: auto;
Expand Down
22 changes: 22 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
}


class NotAContainer(Exception):
"""Exception raised if you attempt to add a child to a widget which doesn't permit child nodes."""


_NULL_STYLE = Style()


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

ALLOW_CHILDREN: ClassVar[bool] = True
"""Set to `False` to prevent adding children to this widget."""

can_focus: bool = False
"""Widget may receive focus."""
can_focus_children: bool = True
Expand Down Expand Up @@ -488,6 +495,21 @@ def tooltip(self, tooltip: RenderableType | None):
except NoScreen:
pass

def compose_add_child(self, widget: Widget) -> None:
"""Add a node to children.
This is used by the compose process when it adds children.
There is no need to use it directly, but you may want to override it in a subclass
if you want children to be attached to a different node.
Args:
widget: A Widget to add.
"""
_rich_traceback_omit = True
if not self.ALLOW_CHILDREN:
raise NotAContainer(f"Can't add children to {type(widget)} widgets")
self._nodes._append(widget)

def __enter__(self) -> Self:
"""Use as context manager when composing."""
self.app._compose_stacks[-1].append(self)
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class Button(Widget, can_focus=True):

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

ALLOW_CHILDREN = False

label: reactive[TextType] = reactive[TextType]("")
"""The text label that appears within the button."""

Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Static(Widget, inherit_bindings=False):
}
"""

ALLOW_CHILDREN = False

_renderable: RenderableType

def __init__(
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_toggle_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class ToggleButton(Static, can_focus=True):
| `toggle--label` | Targets the text label of the toggle button. |
"""

ALLOW_CHILDREN = False

DEFAULT_CSS = """
ToggleButton {
width: auto;
Expand Down
20 changes: 18 additions & 2 deletions tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from textual.css.query import NoMatches
from textual.geometry import Offset, Size
from textual.message import Message
from textual.widget import MountError, PseudoClasses, Widget
from textual.widgets import Label, LoadingIndicator
from textual.widget import MountError, NotAContainer, PseudoClasses, Widget
from textual.widgets import Label, LoadingIndicator, Static


@pytest.mark.parametrize(
Expand Down Expand Up @@ -394,3 +394,19 @@ class TestWidgetIsMountedApp(App):
assert widget.is_mounted is False
await pilot.app.mount(widget)
assert widget.is_mounted is True


async def test_not_allow_children():
"""Regression test for https://github.com/Textualize/textual/pull/3758"""

class TestAppExpectFail(App):
def compose(self) -> ComposeResult:
# Statics don't have children, so this should error
with Static():
yield Label("foo")

app = TestAppExpectFail()

with pytest.raises(NotAContainer):
async with app.run_test():
pass

0 comments on commit 4058e59

Please sign in to comment.