Skip to content

added textual.getters #5930

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added textual.getters https://github.com/Textualize/textual/pull/5930

### Changed

- Potential breaking change: Changed default `query_one` and `query_exactly_one` search to breadth first https://github.com/Textualize/textual/pull/5930

## [3.6.0] - 2025-07-06

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions docs/api/getters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "textual.getters"
---

::: textual.getters
13 changes: 8 additions & 5 deletions examples/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
raise ImportError("Please install httpx with 'pip install httpx' ")


from textual import work
from textual import getters, work
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Input, Markdown
Expand All @@ -17,6 +17,9 @@ class DictionaryApp(App):

CSS_PATH = "dictionary.tcss"

results = getters.query_one("#results", Markdown)
input = getters.query_one(Input)

def compose(self) -> ComposeResult:
yield Input(placeholder="Search for a word", id="dictionary-search")
with VerticalScroll(id="results-container"):
Expand All @@ -28,7 +31,7 @@ async def on_input_changed(self, message: Input.Changed) -> None:
self.lookup_word(message.value)
else:
# Clear the results
await self.query_one("#results", Markdown).update("")
await self.results.update("")

@work(exclusive=True)
async def lookup_word(self, word: str) -> None:
Expand All @@ -40,12 +43,12 @@ async def lookup_word(self, word: str) -> None:
try:
results = response.json()
except Exception:
self.query_one("#results", Markdown).update(response.text)
self.results.update(response.text)
return

if word == self.query_one(Input).value:
if word == self.input.value:
markdown = self.make_word_markdown(results)
self.query_one("#results", Markdown).update(markdown)
self.results.update(markdown)

def make_word_markdown(self, results: object) -> str:
"""Convert the results into markdown."""
Expand Down
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ nav:
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/getters.md"
- "api/layout.md"
- "api/lazy.md"
- "api/logger.md"
Expand Down
4 changes: 2 additions & 2 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -1484,7 +1484,7 @@ def query_one(
else:
cache_key = None

for node in walk_depth_first(base_node, with_root=False):
for node in walk_breadth_first(base_node, with_root=False):
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
Expand Down Expand Up @@ -1555,7 +1555,7 @@ def query_exactly_one(
else:
cache_key = None

children = walk_depth_first(base_node, with_root=False)
children = walk_breadth_first(base_node, with_root=False)
iter_children = iter(children)
for node in iter_children:
if not match(selector_set, node):
Expand Down
177 changes: 177 additions & 0 deletions src/textual/getters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Descriptors to define properties on your widget, screen, or App.

"""

from __future__ import annotations

from typing import Generic, overload

from textual.css.query import NoMatches, QueryType, WrongType
from textual.dom import DOMNode
from textual.widget import Widget


class query_one(Generic[QueryType]):
"""Create a query one property.

A query one property calls [Widget.query_one][textual.dom.DOMNode.query_one] when accessed, and returns
a widget. If the widget doesn't exist, then the property will raise the same exceptions as `Widget.query_one`.


Example:
```python
from textual import getters

class MyScreen(screen):

# Note this is at the class level
output_log = getters.query_one("#output", RichLog)

def compose(self) -> ComposeResult:
with containers.Vertical():
yield RichLog(id="output")

def on_mount(self) -> None:
self.output_log.write("Screen started")
# Equivalent to the following line:
# self.query_one("#output", RichLog).write("Screen started")
```


"""

selector: str
expect_type: type[Widget]

@overload
def __init__(self, selector: str) -> None:
self.selector = selector
self.expect_type = Widget

@overload
def __init__(self, selector: type[QueryType]) -> None:
self.selector = selector.__name__
self.expect_type = selector

@overload
def __init__(self, selector: str, expect_type: type[QueryType]) -> None:
self.selector = selector
self.expect_type = expect_type

@overload
def __init__(self, selector: type[QueryType], expect_type: type[QueryType]) -> None:
self.selector = selector.__name__
self.expect_type = expect_type

def __init__(
self,
selector: str | type[QueryType],
expect_type: type[QueryType] | None = None,
) -> None:
if expect_type is None:
self.expect_type = Widget
else:
self.expect_type = expect_type
if isinstance(selector, str):
self.selector = selector
else:
self.selector = selector.__name__
self.expect_type = selector

@overload
def __get__(
self: "query_one[QueryType]", obj: DOMNode, obj_type: type[DOMNode]
) -> QueryType: ...

@overload
def __get__(
self: "query_one[QueryType]", obj: None, obj_type: type[DOMNode]
) -> "query_one[QueryType]": ...

def __get__(
self: "query_one[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode]
) -> QueryType | Widget | "query_one":
"""Get the widget matching the selector and/or type."""
if obj is None:
return self
query_node = obj.query_one(self.selector, self.expect_type)
return query_node


class child_by_id(Generic[QueryType]):
"""Create a child_by_id property, which returns the child with the given ID.

This is similar using [query_one][textual.getters.query_one] with an id selector, except that
only the immediate children are considered. It is also more efficient, as it doesn't need to search the DOM.


Example:
```python
from textual import getters

class MyScreen(screen):

# Note this is at the class level
output_log = getters.child_by_id("#output", RichLog)

def compose(self) -> ComposeResult:
yield RichLog(id="output")

def on_mount(self) -> None:
self.output_log.write("Screen started")
```


"""

child_id: str
expect_type: type[Widget]

@overload
def __init__(self, child_id: str) -> None:
self.child_id = child_id
self.expect_type = Widget

@overload
def __init__(self, child_id: str, expect_type: type[QueryType]) -> None:
self.child_id = child_id
self.expect_type = expect_type

def __init__(
self,
child_id: str,
expect_type: type[QueryType] | None = None,
) -> None:
if expect_type is None:
self.expect_type = Widget
else:
self.expect_type = expect_type
self.child_id = child_id

@overload
def __get__(
self: "child_by_id[QueryType]", obj: DOMNode, obj_type: type[DOMNode]
) -> QueryType: ...

@overload
def __get__(
self: "child_by_id[QueryType]", obj: None, obj_type: type[DOMNode]
) -> "child_by_id[QueryType]": ...

def __get__(
self: "child_by_id[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode]
) -> QueryType | Widget | "child_by_id":
"""Get the widget matching the selector and/or type."""
if obj is None:
return self
child = obj._nodes._get_by_id(self.child_id)
if child is None:
raise NoMatches(f"No child found with id={id!r}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the id should be self.child_id.

if not isinstance(child, self.expect_type):
if not isinstance(child, self.expect_type):
raise WrongType(
f"Child with id={id!r} is wrong type; expected {self.expect_type}, got"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the id should be self.child_id.

f" {type(child)}"
)
return child
45 changes: 45 additions & 0 deletions tests/test_getters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import pytest

from textual import containers, getters
from textual.app import App, ComposeResult
from textual.css.query import NoMatches, WrongType
from textual.widget import Widget
from textual.widgets import Input, Label


async def get_getters() -> None:
"""Check the getter descriptors work, and return expected errors."""

class QueryApp(App):
label1 = getters.query_one("#label1", Label)
label2 = getters.child_by_id("label2", Label)
label1_broken = getters.query_one("#label1", Input)
label2_broken = getters.child_by_id("label2", Input)
label1_missing = getters.query_one("#foo", Widget)
label2_missing = getters.child_by_id("bar", Widget)

def compose(self) -> ComposeResult:
with containers.Vertical():
yield Label(id="label1", classes=".red")
yield Label(id="label2", classes=".green")

app = QueryApp()
async with app.run_test():

assert isinstance(app.label1, Label)
assert app.label1.id == "label1"

assert isinstance(app.label2, Label)
assert app.label2.id == "label2"

with pytest.raises(WrongType):
app.label1_broken

with pytest.raises(WrongType):
app.label2_broken

with pytest.raises(NoMatches):
app.label1_missing

with pytest.raises(NoMatches):
app.label2_missing
Loading