From bda27909516e792ec3fb5f3353f62658c1a33998 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 08:08:21 -0400 Subject: [PATCH 1/2] P407: accept abstract capabilities in ancestor query selectors --- src/textual/css/query.py | 2 ++ src/textual/dom.py | 51 +++++++++++++++++++++++++++------------- tests/test_query.py | 19 +++++++++++++++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 05613e5b52..4f10222811 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -50,6 +50,8 @@ class WrongType(QueryError): QueryType = TypeVar("QueryType", bound="Widget") """Type variable used to type generic queries.""" +AncestorType = TypeVar("AncestorType") +"""A nominal ancestor capability, not necessarily a concrete widget class.""" ExpectType = TypeVar("ExpectType") """Type variable used to further restrict queries.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index dca74792ba..e825191dd3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -57,12 +57,12 @@ from textual.worker_manager import WorkerManager if TYPE_CHECKING: - from typing_extensions import Self, TypeAlias + from typing_extensions import Self, TypeAlias, TypeForm from _typeshed import SupportsRichComparison from rich.console import RenderableType from textual.app import App - from textual.css.query import DOMQuery, QueryType + from textual.css.query import AncestorType, DOMQuery, QueryType from textual.css.types import CSSLocation from textual.reactive import _StoredReactiveAccess from textual.screen import Screen @@ -170,7 +170,9 @@ def _update_inherited_geometry(self, geometry: bool) -> bool: geometry = True return self._style_geometry_updated(geometry) - def _style_rules_updated(self, revision: int, geometry: bool, descendants: bool) -> None: + def _style_rules_updated( + self, revision: int, geometry: bool, descendants: bool + ) -> None: """Publish the rule owner's mutation to dependent ancestor subtrees. This is a projection of Styles' mutation epoch, including raw writes @@ -195,7 +197,6 @@ def _style_rules_updated(self, revision: int, geometry: bool, descendants: bool) def _make_component_node(self, component: str) -> DOMNode: return DOMNode(classes=component) - DEFAULT_CSS: ClassVar[str] = "" """Default TCSS.""" @@ -239,7 +240,6 @@ def _make_component_node(self, component: str) -> DOMNode: _reactives: ClassVar[dict[str, Reactive]] _reactive_accessors: ClassVar[dict[str, _StoredReactiveAccess]] - # Names of potential computed reactives _computes: ClassVar[frozenset[str]] @@ -295,10 +295,16 @@ def _scan_relative_children_height(self, native: bool) -> tuple[bool, bool]: result = True break if height.is_auto: - if type(child)._has_relative_children_height is Widget._has_relative_children_height: + if ( + type(child)._has_relative_children_height + is Widget._has_relative_children_height + ): relative, child_native = child._relative_children_height() else: - relative, child_native = child._has_relative_children_height, False + relative, child_native = ( + child._has_relative_children_height, + False, + ) native = native and child_native if relative: result = True @@ -324,7 +330,9 @@ def _render_container(self): return self.layout.render_keyline(self) return Blank(self.background_colors[1]) - def _measure_content_height(self, container: Size, viewport: Size, width: int) -> int: + def _measure_content_height( + self, container: Size, viewport: Size, width: int + ) -> int: """Intrinsic native height; the caller owns reuse and publication.""" if not width: return 0 @@ -491,7 +499,11 @@ def _component_styles(self) -> dict[str, RenderStyles]: @cached_property def _bindings(self) -> BindingsMap: - return BindingsMap() if self._merged_bindings is None else self._merged_bindings.copy() + return ( + BindingsMap() + if self._merged_bindings is None + else self._merged_bindings.copy() + ) @cached_property def _query_one_cache(self) -> LRUCache[QueryOneCacheKey, DOMNode]: @@ -1076,8 +1088,11 @@ def screen(self) -> "Screen[object]": try: node = next( - (ancestor for ancestor in self.walk_ancestors(with_self=True) - if isinstance(ancestor, Screen)), + ( + ancestor + for ancestor in self.walk_ancestors(with_self=True) + if isinstance(ancestor, Screen) + ), None, ) except AttributeError: @@ -1202,8 +1217,10 @@ def display(self) -> bool: my_widget.display = False # Hide my_widget ``` """ - return not self._display_constraints and self.styles.display != "none" and not ( - self._closing or self._closed or self._pruning + return ( + not self._display_constraints + and self.styles.display != "none" + and not (self._closing or self._closed or self._pruning) ) @display.setter @@ -1800,7 +1817,7 @@ def query_exactly_one( def query_ancestor(self, selector: str) -> DOMNode: ... @overload - def query_ancestor(self, selector: type[QueryType]) -> QueryType: ... + def query_ancestor(self, selector: TypeForm[AncestorType]) -> AncestorType: ... @overload def query_ancestor( @@ -1809,7 +1826,7 @@ def query_ancestor( def query_ancestor( self, - selector: str | type[QueryType], + selector: str | TypeForm[AncestorType], expect_type: type[QueryType] | None = None, ) -> DOMNode: """Get an ancestor which matches a query. @@ -1855,7 +1872,9 @@ def set_styles(self, css: str | None = None, **update_styles: Any) -> Self: self._inline_styles.refresh(layout=True) if update_styles: - self._inline_styles.replace_rules(self._inline_styles.get_rules(), **update_styles) + self._inline_styles.replace_rules( + self._inline_styles.get_rules(), **update_styles + ) return self def has_class(self, *class_names: str) -> bool: diff --git a/tests/test_query.py b/tests/test_query.py index 5fde811450..70c449a407 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + import pytest from textual.app import App, ComposeResult @@ -159,6 +161,23 @@ class App(Widget): _ = app.query(".float").last(View) +def test_query_ancestor_abstract_mixin(): + class Scope: + @abstractmethod + def scope_value(self) -> str: ... + + class ScopeWidget(Widget, Scope): + def scope_value(self) -> str: + return "scope" + + parent = ScopeWidget() + child = Widget() + parent._add_child(child) + + ancestor: Scope = child.query_ancestor(Scope) + assert ancestor is parent + + async def test_query_classes(): class App(Widget): pass From ad554b38d005cb1bf7c533eebcc83bbd8605a612 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 08:17:01 -0400 Subject: [PATCH 2/2] P407: decode ancestor selectors once before the shared walk --- src/textual/dom.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index e825191dd3..37603661dc 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1828,24 +1828,35 @@ def query_ancestor( self, selector: str | TypeForm[AncestorType], expect_type: type[QueryType] | None = None, - ) -> DOMNode: - """Get an ancestor which matches a query. + ) -> DOMNode | AncestorType: + """Get an ancestor which matches a query or nominal capability. Args: - selector: A TCSS selector. + selector: A TCSS selector or ancestor class, including abstract mixins. expect_type: Expected type, or `None` for any DOMNode. Raises: - InvalidQueryFormat: If the selector is invalid. + InvalidQueryFormat: If the CSS selector is invalid. + TypeError: If the selector is neither a CSS string nor a class. NoMatches: If there are no matching ancestors. Returns: A DOMNode or subclass if `expect_type` is provided. """ base_node = self._get_dom_base() - query = self.query(selector) + if isinstance(selector, str): + matches = self.query(selector)._matches + else: + if not isinstance(selector, type): + raise TypeError("Ancestor selectors must be CSS strings or classes") + ancestor_type = selector + + def matches_class(node: DOMNode) -> bool: + return isinstance(node, ancestor_type) + + matches = matches_class for node in base_node.walk_ancestors(): - if not query._matches(node): + if not matches(node): continue if expect_type is not None and not isinstance(node, expect_type): continue