Skip to content

Commit 58d25fb

Browse files
authored
Merge pull request #4950 from Textualize/faster-query
Faster query_one
2 parents 75d71f5 + 78bd0f5 commit 58d25fb

File tree

7 files changed

+145
-34
lines changed

7 files changed

+145
-34
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1010
### Added
1111

1212
- Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940
13+
- Added `DOMNode.query_exactly_one` https://github.com/Textualize/textual/pull/4950
14+
- Added `SelectorSet.is_simple` https://github.com/Textualize/textual/pull/4950
1315

1416
### Changed
1517

1618
- KeyPanel will show multiple keys if bound to the same action https://github.com/Textualize/textual/pull/4940
19+
- Breaking change: `DOMNode.query_one` will not `raise TooManyMatches` https://github.com/Textualize/textual/pull/4950
1720

1821
## [0.78.0] - 2024-08-27
1922

docs/guide/queries.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ send_button = self.query_one("#send")
2121

2222
This will retrieve a widget with an ID of `send`, if there is exactly one.
2323
If there are no matching widgets, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception.
24-
If there is more than one match, Textual will raise a [TooManyMatches][textual.css.query.TooManyMatches] exception.
2524

2625
You can also add a second parameter for the expected type, which will ensure that you get the type you are expecting.
2726

src/textual/_node_list.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
if TYPE_CHECKING:
1010
from _typeshed import SupportsRichComparison
1111

12+
from .dom import DOMNode
1213
from .widget import Widget
1314

1415

@@ -24,7 +25,8 @@ class NodeList(Sequence["Widget"]):
2425
Although named a list, widgets may appear only once, making them more like a set.
2526
"""
2627

27-
def __init__(self) -> None:
28+
def __init__(self, parent: DOMNode | None = None) -> None:
29+
self._parent = parent
2830
# The nodes in the list
2931
self._nodes: list[Widget] = []
3032
self._nodes_set: set[Widget] = set()
@@ -52,6 +54,13 @@ def __len__(self) -> int:
5254
def __contains__(self, widget: object) -> bool:
5355
return widget in self._nodes
5456

57+
def updated(self) -> None:
58+
"""Mark the nodes as having been updated."""
59+
self._updates += 1
60+
node = self._parent
61+
while node is not None and (node := node._parent) is not None:
62+
node._nodes._updates += 1
63+
5564
def _sort(
5665
self,
5766
*,
@@ -69,7 +78,7 @@ def _sort(
6978
else:
7079
self._nodes.sort(key=key, reverse=reverse)
7180

72-
self._updates += 1
81+
self.updated()
7382

7483
def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int:
7584
"""Return the index of the given widget.
@@ -102,7 +111,7 @@ def _append(self, widget: Widget) -> None:
102111
if widget_id is not None:
103112
self._ensure_unique_id(widget_id)
104113
self._nodes_by_id[widget_id] = widget
105-
self._updates += 1
114+
self.updated()
106115

107116
def _insert(self, index: int, widget: Widget) -> None:
108117
"""Insert a Widget.
@@ -117,7 +126,7 @@ def _insert(self, index: int, widget: Widget) -> None:
117126
if widget_id is not None:
118127
self._ensure_unique_id(widget_id)
119128
self._nodes_by_id[widget_id] = widget
120-
self._updates += 1
129+
self.updated()
121130

122131
def _ensure_unique_id(self, widget_id: str) -> None:
123132
if widget_id in self._nodes_by_id:
@@ -141,15 +150,15 @@ def _remove(self, widget: Widget) -> None:
141150
widget_id = widget.id
142151
if widget_id in self._nodes_by_id:
143152
del self._nodes_by_id[widget_id]
144-
self._updates += 1
153+
self.updated()
145154

146155
def _clear(self) -> None:
147156
"""Clear the node list."""
148157
if self._nodes:
149158
self._nodes.clear()
150159
self._nodes_set.clear()
151160
self._nodes_by_id.clear()
152-
self._updates += 1
161+
self.updated()
153162

154163
def __iter__(self) -> Iterator[Widget]:
155164
return iter(self._nodes)

src/textual/css/model.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ def __post_init__(self) -> None:
193193
def css(self) -> str:
194194
return RuleSet._selector_to_css(self.selectors)
195195

196+
@property
197+
def is_simple(self) -> bool:
198+
"""Are all the selectors simple (i.e. only dependent on static DOM state)."""
199+
simple_types = {SelectorType.ID, SelectorType.TYPE}
200+
return all(
201+
(selector.type in simple_types and not selector.pseudo_classes)
202+
for selector in self.selectors
203+
)
204+
196205
def __rich_repr__(self) -> rich.repr.Result:
197206
selectors = RuleSet._selector_to_css(self.selectors)
198207
yield selectors

src/textual/dom.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@
3333
from ._node_list import NodeList
3434
from ._types import WatchCallbackType
3535
from .binding import Binding, BindingsMap, BindingType
36+
from .cache import LRUCache
3637
from .color import BLACK, WHITE, Color
3738
from .css._error_tools import friendly_list
3839
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
3940
from .css.errors import DeclarationError, StyleValueError
40-
from .css.parse import parse_declarations
41+
from .css.match import match
42+
from .css.parse import parse_declarations, parse_selectors
43+
from .css.query import NoMatches, TooManyMatches
4144
from .css.styles import RenderStyles, Styles
4245
from .css.tokenize import IDENTIFIER
4346
from .message_pump import MessagePump
@@ -60,7 +63,7 @@
6063
from .worker import Worker, WorkType, ResultType
6164

6265
# Unused & ignored imports are needed for the docs to link to these objects:
63-
from .css.query import NoMatches, TooManyMatches, WrongType # type: ignore # noqa: F401
66+
from .css.query import WrongType # type: ignore # noqa: F401
6467

6568
from typing_extensions import Literal
6669

@@ -74,6 +77,10 @@
7477
ReactiveType = TypeVar("ReactiveType")
7578

7679

80+
QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget] | None]"
81+
"""The key used to cache query_one results."""
82+
83+
7784
class BadIdentifier(Exception):
7885
"""Exception raised if you supply a `id` attribute or class name in the wrong format."""
7986

@@ -184,13 +191,14 @@ def __init__(
184191
self._name = name
185192
self._id = None
186193
if id is not None:
187-
self.id = id
194+
check_identifiers("id", id)
195+
self._id = id
188196

189197
_classes = classes.split() if classes else []
190198
check_identifiers("class name", *_classes)
191199
self._classes.update(_classes)
192200

193-
self._nodes: NodeList = NodeList()
201+
self._nodes: NodeList = NodeList(self)
194202
self._css_styles: Styles = Styles(self)
195203
self._inline_styles: Styles = Styles(self)
196204
self.styles: RenderStyles = RenderStyles(
@@ -213,6 +221,8 @@ def __init__(
213221
dict[str, tuple[MessagePump, Reactive | object]] | None
214222
) = None
215223
self._pruning = False
224+
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
225+
216226
super().__init__()
217227

218228
def set_reactive(
@@ -741,7 +751,7 @@ def id(self, new_id: str) -> str:
741751
ValueError: If the ID has already been set.
742752
"""
743753
check_identifiers("id", new_id)
744-
754+
self._nodes.updated()
745755
if self._id is not None:
746756
raise ValueError(
747757
f"Node 'id' attribute may not be changed once set (current id={self._id!r})"
@@ -1393,21 +1403,110 @@ def query_one(
13931403
Raises:
13941404
WrongType: If the wrong type was found.
13951405
NoMatches: If no node matches the query.
1396-
TooManyMatches: If there is more than one matching node in the query.
13971406
13981407
Returns:
13991408
A widget matching the selector.
14001409
"""
14011410
_rich_traceback_omit = True
1402-
from .css.query import DOMQuery
14031411

14041412
if isinstance(selector, str):
14051413
query_selector = selector
14061414
else:
14071415
query_selector = selector.__name__
1408-
query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector)
14091416

1410-
return query.only_one() if expect_type is None else query.only_one(expect_type)
1417+
selector_set = parse_selectors(query_selector)
1418+
1419+
if all(selectors.is_simple for selectors in selector_set):
1420+
cache_key = (self._nodes._updates, query_selector, expect_type)
1421+
cached_result = self._query_one_cache.get(cache_key)
1422+
if cached_result is not None:
1423+
return cached_result
1424+
else:
1425+
cache_key = None
1426+
1427+
for node in walk_depth_first(self, with_root=False):
1428+
if not match(selector_set, node):
1429+
continue
1430+
if expect_type is not None and not isinstance(node, expect_type):
1431+
continue
1432+
if cache_key is not None:
1433+
self._query_one_cache[cache_key] = node
1434+
return node
1435+
1436+
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
1437+
1438+
if TYPE_CHECKING:
1439+
1440+
@overload
1441+
def query_exactly_one(self, selector: str) -> Widget: ...
1442+
1443+
@overload
1444+
def query_exactly_one(self, selector: type[QueryType]) -> QueryType: ...
1445+
1446+
@overload
1447+
def query_exactly_one(
1448+
self, selector: str, expect_type: type[QueryType]
1449+
) -> QueryType: ...
1450+
1451+
def query_exactly_one(
1452+
self,
1453+
selector: str | type[QueryType],
1454+
expect_type: type[QueryType] | None = None,
1455+
) -> QueryType | Widget:
1456+
"""Get a widget from this widget's children that matches a selector or widget type.
1457+
1458+
!!! Note
1459+
This method is similar to [query_one][textual.dom.DOMNode.query_one].
1460+
The only difference is that it will raise `TooManyMatches` if there is more than a single match.
1461+
1462+
Args:
1463+
selector: A selector or widget type.
1464+
expect_type: Require the object be of the supplied type, or None for any type.
1465+
1466+
Raises:
1467+
WrongType: If the wrong type was found.
1468+
NoMatches: If no node matches the query.
1469+
TooManyMatches: If there is more than one matching node in the query (and `exactly_one==True`).
1470+
1471+
Returns:
1472+
A widget matching the selector.
1473+
"""
1474+
_rich_traceback_omit = True
1475+
1476+
if isinstance(selector, str):
1477+
query_selector = selector
1478+
else:
1479+
query_selector = selector.__name__
1480+
1481+
selector_set = parse_selectors(query_selector)
1482+
1483+
if all(selectors.is_simple for selectors in selector_set):
1484+
cache_key = (self._nodes._updates, query_selector, expect_type)
1485+
cached_result = self._query_one_cache.get(cache_key)
1486+
if cached_result is not None:
1487+
return cached_result
1488+
else:
1489+
cache_key = None
1490+
1491+
children = walk_depth_first(self, with_root=False)
1492+
iter_children = iter(children)
1493+
for node in iter_children:
1494+
if not match(selector_set, node):
1495+
continue
1496+
if expect_type is not None and not isinstance(node, expect_type):
1497+
continue
1498+
for later_node in iter_children:
1499+
if match(selector_set, later_node):
1500+
if expect_type is not None and not isinstance(node, expect_type):
1501+
continue
1502+
raise TooManyMatches(
1503+
"Call to query_one resulted in more than one matched node"
1504+
)
1505+
if cache_key is not None:
1506+
self._query_one_cache[cache_key] = node
1507+
return node
1508+
1509+
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
14111510

14121511
def set_styles(self, css: str | None = None, **update_styles: Any) -> Self:
14131512
"""Set custom styles on this object.

src/textual/widget.py

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@
8787
from .renderables.blank import Blank
8888
from .rlock import RLock
8989
from .strip import Strip
90-
from .walk import walk_depth_first
9190

9291
if TYPE_CHECKING:
9392
from .app import App, ComposeResult
@@ -807,21 +806,14 @@ def get_widget_by_id(
807806
NoMatches: if no children could be found for this ID.
808807
WrongType: if the wrong type was found.
809808
"""
810-
# We use Widget as a filter_type so that the inferred type of child is Widget.
811-
for child in walk_depth_first(self, filter_type=Widget):
812-
try:
813-
if expect_type is None:
814-
return child.get_child_by_id(id)
815-
else:
816-
return child.get_child_by_id(id, expect_type=expect_type)
817-
except NoMatches:
818-
pass
819-
except WrongType as exc:
820-
raise WrongType(
821-
f"Descendant with id={id!r} is wrong type; expected {expect_type},"
822-
f" got {type(child)}"
823-
) from exc
824-
raise NoMatches(f"No descendant found with id={id!r}")
809+
810+
widget = self.query_one(f"#{id}")
811+
if expect_type is not None and not isinstance(widget, expect_type):
812+
raise WrongType(
813+
f"Descendant with id={id!r} is wrong type; expected {expect_type},"
814+
f" got {type(widget)}"
815+
)
816+
return widget
825817

826818
def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType:
827819
"""Get the first immediate child of a given type.
@@ -958,7 +950,7 @@ def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]:
958950
# can be passed to query_one. So let's use that to get a widget to
959951
# work on.
960952
if isinstance(spot, str):
961-
spot = self.query_one(spot, Widget)
953+
spot = self.query_exactly_one(spot, Widget)
962954

963955
# At this point we should have a widget, either because we got given
964956
# one, or because we pulled one out of the query. First off, does it

tests/test_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class App(Widget):
103103
assert app.query_one("#widget1") == widget1
104104
assert app.query_one("#widget1", Widget) == widget1
105105
with pytest.raises(TooManyMatches):
106-
_ = app.query_one(Widget)
106+
_ = app.query_exactly_one(Widget)
107107

108108
assert app.query("Widget.float")[0] == sidebar
109109
assert app.query("Widget.float")[0:2] == [sidebar, tooltip]

0 commit comments

Comments
 (0)