Skip to content

Commit e5f5fb9

Browse files
authored
Merge pull request #5948 from Textualize/query-one-optimize
Optimize query_one
2 parents 8178119 + 7209960 commit e5f5fb9

File tree

4 files changed

+100
-6
lines changed

4 files changed

+100
-6
lines changed

src/textual/css/parse.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import re
45
from functools import lru_cache
56
from typing import Iterable, Iterator, NoReturn
67

@@ -16,7 +17,13 @@
1617
SelectorType,
1718
)
1819
from textual.css.styles import Styles
19-
from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
20+
from textual.css.tokenize import (
21+
IDENTIFIER,
22+
Token,
23+
tokenize,
24+
tokenize_declarations,
25+
tokenize_values,
26+
)
2027
from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
2128
from textual.css.types import CSSLocation, Specificity3
2229
from textual.suggestions import get_suggestion
@@ -33,6 +40,21 @@
3340
"nested": (SelectorType.NESTED, (0, 0, 0)),
3441
}
3542

43+
RE_ID_SELECTOR = re.compile("#" + IDENTIFIER)
44+
45+
46+
@lru_cache(maxsize=128)
47+
def is_id_selector(selector: str) -> bool:
48+
"""Is the selector a single ID selector, i.e. "#foo"?
49+
50+
Args:
51+
selector: A CSS selector.
52+
53+
Returns:
54+
`True` if the selector is a simple ID selector, otherwise `False`.
55+
"""
56+
return RE_ID_SELECTOR.fullmatch(selector) is not None
57+
3658

3759
def _add_specificity(
3860
specificity1: Specificity3, specificity2: Specificity3

src/textual/dom.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from textual.css.constants import VALID_DISPLAY, VALID_VISIBILITY
4141
from textual.css.errors import DeclarationError, StyleValueError
4242
from textual.css.match import match
43-
from textual.css.parse import parse_declarations, parse_selectors
43+
from textual.css.parse import is_id_selector, parse_declarations, parse_selectors
4444
from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType
4545
from textual.css.styles import RenderStyles, Styles
4646
from textual.css.tokenize import IDENTIFIER
@@ -49,7 +49,7 @@
4949
from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
5050
from textual.style import Style as VisualStyle
5151
from textual.timer import Timer
52-
from textual.walk import walk_breadth_first, walk_depth_first
52+
from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first
5353
from textual.worker_manager import WorkerManager
5454

5555
if TYPE_CHECKING:
@@ -1466,6 +1466,24 @@ def query_one(
14661466
else:
14671467
query_selector = selector.__name__
14681468

1469+
if is_id_selector(query_selector):
1470+
cache_key = (base_node._nodes._updates, query_selector, expect_type)
1471+
cached_result = base_node._query_one_cache.get(cache_key)
1472+
if cached_result is not None:
1473+
return cached_result
1474+
if (
1475+
node := walk_breadth_search_id(
1476+
base_node, query_selector[1:], with_root=False
1477+
)
1478+
) is not None:
1479+
if expect_type is not None and not isinstance(node, expect_type):
1480+
raise WrongType(
1481+
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
1482+
)
1483+
base_node._query_one_cache[cache_key] = node
1484+
return node
1485+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
1486+
14691487
try:
14701488
selector_set = parse_selectors(query_selector)
14711489
except TokenError:
@@ -1492,7 +1510,7 @@ def query_one(
14921510
base_node._query_one_cache[cache_key] = node
14931511
return node
14941512

1495-
raise NoMatches(f"No nodes match {selector!r} on {base_node!r}")
1513+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
14961514

14971515
if TYPE_CHECKING:
14981516

@@ -1572,7 +1590,7 @@ def query_exactly_one(
15721590
base_node._query_one_cache[cache_key] = node
15731591
return node
15741592

1575-
raise NoMatches(f"No nodes match {selector!r} on {base_node!r}")
1593+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
15761594

15771595
if TYPE_CHECKING:
15781596

src/textual/walk.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,33 @@ def walk_breadth_first(
137137
if isinstance(node, check_type):
138138
yield node
139139
extend(node._nodes)
140+
141+
142+
def walk_breadth_search_id(
143+
root: DOMNode, node_id: str, *, with_root: bool = True
144+
) -> DOMNode | None:
145+
"""Special case to walk breadth first searching for a node with a given id.
146+
147+
This is more efficient than [walk_breadth_first][textual.walk.walk_breadth_first] for this special case, as it can use an index.
148+
149+
Args:
150+
root: The root node (starting point).
151+
node_id: Node id to search for.
152+
with_root: Consider the root node? If the root has the node id, then return it.
153+
154+
Returns:
155+
A DOMNode if a node was found, otherwise `None`.
156+
"""
157+
158+
if with_root and root.id == node_id:
159+
return root
160+
161+
queue: deque[DOMNode] = deque()
162+
queue.append(root)
163+
164+
while queue:
165+
node = queue.popleft()
166+
if (found_node := node._nodes._get_by_id(node_id)) is not None:
167+
return found_node
168+
queue.extend(node._nodes)
169+
return None

tests/css/test_parse.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from textual.color import Color
66
from textual.css.errors import UnresolvedVariableError
7-
from textual.css.parse import substitute_references
7+
from textual.css.parse import is_id_selector, substitute_references
88
from textual.css.scalar import Scalar, Unit
99
from textual.css.stylesheet import Stylesheet, StylesheetParseError
1010
from textual.css.tokenize import tokenize
@@ -1272,3 +1272,27 @@ def test_parse_bad_pseudo_selector_with_suggestion():
12721272
stylesheet.parse()
12731273

12741274
assert error.value.start == (2, 7)
1275+
1276+
1277+
@pytest.mark.parametrize(
1278+
"selector, expected",
1279+
[
1280+
# True cases
1281+
("#foo", True),
1282+
("#bar", True),
1283+
("#f", True),
1284+
# False cases
1285+
("#", False),
1286+
("Foo", False),
1287+
(".foo", False),
1288+
("#5foo", False),
1289+
("#foo .bar", False),
1290+
("#foo>.bar", False),
1291+
("#foo.bar", False),
1292+
(".foo #foo", False),
1293+
("#foo #bar", False),
1294+
],
1295+
)
1296+
def test_is_id_selector(selector: str, expected: bool) -> None:
1297+
"""Test is_id_selector is working as expected."""
1298+
assert is_id_selector(selector) is expected

0 commit comments

Comments
 (0)