diff --git a/evidence/message-handler-lookup-20261006/README.md b/evidence/message-handler-lookup-20261006/README.md new file mode 100644 index 0000000000..446e1284de --- /dev/null +++ b/evidence/message-handler-lookup-20261006/README.md @@ -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. diff --git a/evidence/message-handler-lookup-20261006/controls.log b/evidence/message-handler-lookup-20261006/controls.log new file mode 100644 index 0000000000..527ac27e50 --- /dev/null +++ b/evidence/message-handler-lookup-20261006/controls.log @@ -0,0 +1,2 @@ +....................... [100%] +23 passed in 2.36s diff --git a/evidence/message-handler-lookup-20261006/source.json b/evidence/message-handler-lookup-20261006/source.json new file mode 100644 index 0000000000..6ba6323768 --- /dev/null +++ b/evidence/message-handler-lookup-20261006/source.json @@ -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": [] + } +} diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 28323aa749..4118680361 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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, []) @@ -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): diff --git a/tests/test_on.py b/tests/test_on.py index 7483a7be9f..b148ef16f0 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -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."""