From e452077d3e79c91372f5129415bee2f3538da644 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 6 Jul 2025 19:47:55 +0100 Subject: [PATCH 1/6] added textual.getters --- CHANGELOG.md | 6 +++ docs/api/getters.md | 5 +++ examples/dictionary.py | 13 +++--- mkdocs-nav.yml | 1 + src/textual/getters.py | 96 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 docs/api/getters.md create mode 100644 src/textual/getters.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f429fa230..ab283a4f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + ## [3.6.0] - 2025-07-06 ### Fixed diff --git a/docs/api/getters.md b/docs/api/getters.md new file mode 100644 index 0000000000..45a042d3a3 --- /dev/null +++ b/docs/api/getters.md @@ -0,0 +1,5 @@ +--- +title: "textual.getters" +--- + +::: textual.getters diff --git a/examples/dictionary.py b/examples/dictionary.py index 56f4a17689..f68df022cd 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -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 @@ -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"): @@ -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: @@ -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.""" diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a2ef39e727..3be43d7068 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -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" diff --git a/src/textual/getters.py b/src/textual/getters.py new file mode 100644 index 0000000000..bdfaa89616 --- /dev/null +++ b/src/textual/getters.py @@ -0,0 +1,96 @@ +""" +Descriptors to define properties on your widget, screen, or App. + +""" + +from typing import Generic, overload + +from textual.css.query import QueryType +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 [query_one][textual.dom.DOMNode.query_one] when accessed, and returns + a widget. + + + 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: + 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 From d3d66057f9e22cf001f271f9dc7c7727e19f6bc8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 6 Jul 2025 22:02:51 +0100 Subject: [PATCH 2/6] change default query traversal --- CHANGELOG.md | 4 ++++ src/textual/dom.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab283a4f74..90a309b5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added textual.getters +### Changed + +- Potential breaking change: Changed default query search to breadth first + ## [3.6.0] - 2025-07-06 ### Fixed diff --git a/src/textual/dom.py b/src/textual/dom.py index f07645da87..ff683bd791 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1322,7 +1322,7 @@ def walk_children( filter_type: type[WalkType], *, with_self: bool = False, - method: WalkMethod = "depth", + method: WalkMethod = "breadth", reverse: bool = False, ) -> list[WalkType]: ... @@ -1331,7 +1331,7 @@ def walk_children( self, *, with_self: bool = False, - method: WalkMethod = "depth", + method: WalkMethod = "breadth", reverse: bool = False, ) -> list[DOMNode]: ... @@ -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): @@ -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): From beb0714c938d402530ad983032870d68e94980ad Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 08:23:44 +0100 Subject: [PATCH 3/6] annotations --- src/textual/getters.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/getters.py b/src/textual/getters.py index bdfaa89616..cf0d6e8432 100644 --- a/src/textual/getters.py +++ b/src/textual/getters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """ Descriptors to define properties on your widget, screen, or App. From e4a20b43fb098352be4cfbd424d706a3c52718ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 08:56:20 +0100 Subject: [PATCH 4/6] tests --- src/textual/dom.py | 4 +- src/textual/getters.py | 91 +++++++++++++++++++++++++++++++++++++++--- tests/test_getters.py | 45 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 tests/test_getters.py diff --git a/src/textual/dom.py b/src/textual/dom.py index ff683bd791..c91f6cfc0b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1322,7 +1322,7 @@ def walk_children( filter_type: type[WalkType], *, with_self: bool = False, - method: WalkMethod = "breadth", + method: WalkMethod = "depth", reverse: bool = False, ) -> list[WalkType]: ... @@ -1331,7 +1331,7 @@ def walk_children( self, *, with_self: bool = False, - method: WalkMethod = "breadth", + method: WalkMethod = "depth", reverse: bool = False, ) -> list[DOMNode]: ... diff --git a/src/textual/getters.py b/src/textual/getters.py index cf0d6e8432..3cae9b8aa1 100644 --- a/src/textual/getters.py +++ b/src/textual/getters.py @@ -1,13 +1,13 @@ -from __future__ import annotations - """ Descriptors to define properties on your widget, screen, or App. """ +from __future__ import annotations + from typing import Generic, overload -from textual.css.query import QueryType +from textual.css.query import NoMatches, QueryType, WrongType from textual.dom import DOMNode from textual.widget import Widget @@ -15,8 +15,8 @@ class query_one(Generic[QueryType]): """Create a query one property. - A query one property calls [query_one][textual.dom.DOMNode.query_one] when accessed, and returns - a widget. + 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: @@ -29,7 +29,8 @@ class MyScreen(screen): output_log = getters.query_one("#output", RichLog) def compose(self) -> ComposeResult: - yield RichLog(id="output") + with containers.Vertical(): + yield RichLog(id="output") def on_mount(self) -> None: self.output_log.write("Screen started") @@ -96,3 +97,81 @@ def __get__( 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}") + 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" + f" {type(child)}" + ) + return child diff --git a/tests/test_getters.py b/tests/test_getters.py new file mode 100644 index 0000000000..4ccfbf85e1 --- /dev/null +++ b/tests/test_getters.py @@ -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 From 1f1fa792599795db8e71d5823820fed33a8542c6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 08:57:41 +0100 Subject: [PATCH 5/6] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a309b5c0..51eadcb1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Potential breaking change: Changed default query search to breadth first +- Potential breaking change: Changed default `query_one` and `query_exactly_one` search to breadth first ## [3.6.0] - 2025-07-06 From a01d22d579e7477c7b3a77f7cbec0a1bece7988c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 09:08:11 +0100 Subject: [PATCH 6/6] Changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51eadcb1f9..00acfeaa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added textual.getters +- 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 +- 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