Skip to content
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
43 changes: 43 additions & 0 deletions evidence/message-handler-lookup-20261006/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Native handler lookup

The native metaclass already collects decorated functions and parsed selectors
at class construction. Unlike Core MroDispatch before its refactor, native
dispatch does not scan every method on every event. A new declaration registry
would duplicate that owner and could freeze legitimate method replacement.

The remaining repeated work is inside MessagePump._get_dispatch_methods:
class.__dict__ was acquired up to three times per owner, the private handler
name was constructed per owner, and the message MRO was filtered even if no
decorated declaration used it. Now each owner uses one live mappingproxy, the
private name is prepared once, and the captured message MRO is filtered only
when decorated handlers require it. No persistent state, event cache or wrapper.

The original full message MRO is captured before the first yield, preserving
its invocation lifetime even if classes change while a handler awaits. Class
mappingproxies remain live: replacements during decorated delivery are visible
to the following naming lookup. Decorated declaration lists, their duplicate
suppression and post-yield ordering remain original. Every invocation still
binds the actual function to its current instance and class. Selectors still
read current message attributes and match the current widget. Default-action
checks, namespace naming, private/public precedence and bubbling are unchanged.
Native inheritance deliberately calls declared handlers across C3 owners;
Core-style unannotated masking was not imposed on this different contract.

Parent weak references and ancestor traversal remain live DOM/lifetime answers,
not class declarations. This change neither freezes them nor changes readiness,
geometry, publication, queues or pump task custody.

23 affected checks passed in 2.36s: existing decorator, message handling and
message-pump controls plus one real App C3/private-precedence/dynamic replacement
control. That control changes the named handler while the decorated yield is
suspended, then exercises another App instance to verify fresh binding.
controls.log retains the terminal result. No provider, package, public App or
installed pin operation. No latency gain or CPU attribution claimed from the
original sampled stack-transition groups. Parent owns matching App recording.

source.json uses the existing refactor-audit Package parser for native and Toad
declarations/lexical consumers, with no parse omissions. Dynamic external
metaclasses that fabricate dictionary views cannot be inferred from lexical
sites; the native owner uses real class mappingproxies. Arbitrary mutation of
decorated declaration lists remains the original live-list behavior. No class
method or annotation supply was frozen.
2 changes: 2 additions & 0 deletions evidence/message-handler-lookup-20261006/controls.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
....................... [100%]
23 passed in 2.36s
38 changes: 38 additions & 0 deletions evidence/message-handler-lookup-20261006/source.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"native": {
"modules": 249,
"omissions": [],
"sites": [
[
"src/textual/message_pump.py",
83,
"__new__"
],
[
"src/textual/message_pump.py",
819,
"_get_dispatch_methods"
],
[
"src/textual/message_pump.py",
890,
"_on_message"
],
[
"src/textual/message_pump.py",
901,
"self._get_dispatch_methods"
],
[
"src/textual/message_pump.py",
759,
"self._get_dispatch_methods"
]
]
},
"toad": {
"modules": 288,
"omissions": [],
"sites": []
}
}
15 changes: 10 additions & 5 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,19 +828,24 @@ def _get_dispatch_methods(
from textual.widget import Widget

methods_dispatched: set[Callable] = set()
message_mro = [
_type for _type in message.__class__.__mro__ if issubclass(_type, Message)
]
message_types = message.__class__.__mro__
message_mro: list[type[Message]] | None = None
private_method_name = f"_{method_name}"
for cls in self.__class__.__mro__:
if message._no_default_action:
break
declarations = cls.__dict__
# Try decorated handlers first
decorated_handlers = cast(
"dict[type[Message], list[tuple[Callable, dict[str, tuple[SelectorSet, ...]]]]] | None",
cls.__dict__.get("_decorated_handlers"),
declarations.get("_decorated_handlers"),
)

if decorated_handlers:
if message_mro is None:
message_mro = [
_type for _type in message_types if issubclass(_type, Message)
]
for message_class in message_mro:
handlers = decorated_handlers.get(message_class, [])

Expand Down Expand Up @@ -869,7 +874,7 @@ def _get_dispatch_methods(

# Fall back to the naming convention
# But avoid calling the handler if it was decorated
method = cls.__dict__.get(f"_{method_name}") or cls.__dict__.get(
method = declarations.get(private_method_name) or declarations.get(
method_name
)
if method is not None and not getattr(method, "_textual_on", None):
Expand Down
52 changes: 52 additions & 0 deletions tests/test_on.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,58 @@
from textual.widgets import Button, TabbedContent, TabPane


async def test_live_named_replacement_during_decorated_dispatch():
class Notice(Message, namespace="lookup", bubble=False):
pass

class BaseApp(App):
def _on_lookup_notice(self):
self.received.append("base")

class Left(BaseApp):
def on_lookup_notice(self):
self.received.append("left")

class Right(BaseApp):
def on_lookup_notice(self):
self.received.append("right")

class DispatchApp(Left, Right):
def __init__(self):
super().__init__()
self.received = []

def _on_lookup_notice(self):
self.received.append("old")

def on_lookup_notice(self):
raise AssertionError("private handler has precedence")

@on(Notice)
async def observe(self):
self.received.append("decorated")
# The generator is suspended at its decorated yield. Its captured
# class mapping must still expose real method replacement.
def replacement(instance):
instance.received.append("new")

type(self)._on_lookup_notice = replacement

app = DispatchApp()
async with app.run_test() as pilot:
app.post_message(Notice())
await pilot.pause()
assert app.received == ["decorated", "new", "left", "right", "base"]

# Binding belongs to the current instance, not a cached bound method.
second = DispatchApp()
async with second.run_test() as pilot:
second.post_message(Notice())
await pilot.pause()
assert second.received == ["decorated", "new", "left", "right", "base"]
assert app.received == ["decorated", "new", "left", "right", "base"]


async def test_on_button_pressed() -> None:
"""Test handlers with @on decorator."""

Expand Down
Loading