diff --git a/evidence/callback-binding-arity-20261004/SANITY.json b/evidence/callback-binding-arity-20261004/SANITY.json new file mode 100644 index 0000000000..739bf54d0e --- /dev/null +++ b/evidence/callback-binding-arity-20261004/SANITY.json @@ -0,0 +1,20 @@ +{ + "qualification": "Native source callable controls and actual native App watcher dispatch", + "production_head": "4a8304f355b0cf8be79cf82606a943639904ae2f", + "command": "PYTHONPATH=src python -m pytest -q tests/test_count_parameters.py", + "exit_code": 0, + "passed": 7, + "elapsed_seconds": 0.38, + "observed_stdout": "....... [100%]\n7 passed in 0.38s\n", + "cases": [ + "existing plain functions, methods and partials", + "bound-first and unbound-first discovery", + "partial of bound and unbound original method", + "uncacheable Python callable", + "actual native App reactive watcher followed by unbound and partial invoke" + ], + "installed_package_writes": false, + "physical_recording": false, + "provider_inputs": 0, + "measured_frame_gain": false +} diff --git a/evidence/callback-binding-arity-20261004/SCOPE.md b/evidence/callback-binding-arity-20261004/SCOPE.md new file mode 100644 index 0000000000..c3f76b7b10 --- /dev/null +++ b/evidence/callback-binding-arity-20261004/SCOPE.md @@ -0,0 +1,27 @@ +# Callback binding and original arity + +The existing `count_parameters` owner serves `invoke` and reactive watchers. +Python bound methods expose attributes on their original function. Storing a +bound arity there therefore makes later unbound counts wrong; looking up that +hint before deriving binding also makes bound counts wrong when the unbound +function was counted first. Partial counting bypasses the underlying owner. + +This independent API correction keeps the existing function hint as raw +arity, derive binding before reading it, and derive partial arity through the +same original owner. Python `MethodType` declares actual binding; built-in +callables retain their own signature and need not support writable hints. No +new cache, callable wrapper or state owner is added. Both `invoke` and reactive +watchers consume the corrected existing API. Native viewport projection PR63 +stays frozen and independent. + +The claim that every repeated bound count inspects its signature was incorrect: +CPython's method attribute lookup delegates to the original function. That +performance claim is withdrawn. No frame-time gain is claimed here. + +The complete native before/after AST parses 249 modules without omissions; +`owner-consumers.json` records the original hint writes and count consumers. +The final source batch passed seven controls in 0.38 seconds, including both +discovery orders, partial binding, uncacheable callables, and actual native App +reactive dispatch followed by unbound/partial invocation. See `SANITY.json`. +This qualifies the source API; no installed package, wheel, recording or provider +purpose has been used. Dynamic external metadata is outside AST resolution. diff --git a/evidence/callback-binding-arity-20261004/owner-consumers.json b/evidence/callback-binding-arity-20261004/owner-consumers.json new file mode 100644 index 0000000000..c7a1527399 --- /dev/null +++ b/evidence/callback-binding-arity-20261004/owner-consumers.json @@ -0,0 +1,95 @@ +{ + "scope": "Existing callback arity owner and all native syntactic consumers; dynamic external callable metadata not resolved by AST", + "snapshots": [ + { + "head": "777232c8b960272cb3ddc445df04c9133e6dd6fa", + "parsed": 249, + "omissions": [], + "sites": [ + { + "path": "src/textual/_callback.py", + "line": 55, + "call": "count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 20, + "expression": "func._param_count", + "operation": "Load" + }, + { + "path": "src/textual/_callback.py", + "line": 34, + "expression": "func._param_count", + "operation": "Store" + }, + { + "path": "src/textual/_callback.py", + "line": 24, + "call": "_count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 32, + "call": "_count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 30, + "call": "_count_parameters" + }, + { + "path": "src/textual/reactive.py", + "line": 162, + "call": "count_parameters" + } + ] + }, + { + "head": "4a8304f355b0cf8be79cf82606a943639904ae2f", + "parsed": 249, + "omissions": [], + "sites": [ + { + "path": "src/textual/_callback.py", + "line": 56, + "call": "count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 25, + "expression": "func._param_count", + "operation": "Load" + }, + { + "path": "src/textual/_callback.py", + "line": 33, + "call": "_count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 35, + "expression": "func._param_count", + "operation": "Store" + }, + { + "path": "src/textual/_callback.py", + "line": 23, + "call": "count_parameters" + }, + { + "path": "src/textual/_callback.py", + "line": 29, + "call": "count_parameters" + }, + { + "path": "src/textual/reactive.py", + "line": 162, + "call": "count_parameters" + } + ] + } + ], + "python_binding_source": "https://github.com/python/cpython/blob/v3.14.2/Objects/classobject.c", + "limitation": "No measurement that reflection dominates a frame gap; repeated-bound-reflection claim withdrawn." +} diff --git a/src/textual/_callback.py b/src/textual/_callback.py index d280cbf75f..2c53fec787 100644 --- a/src/textual/_callback.py +++ b/src/textual/_callback.py @@ -3,6 +3,7 @@ import asyncio from functools import partial from inspect import isawaitable, signature +from types import MethodType from typing import TYPE_CHECKING, Any, Callable from textual import active_app @@ -15,24 +16,24 @@ def count_parameters(func: Callable) -> int: - """Count the number of parameters in a callable""" + """Derive callable arity from its original function and argument binding.""" + if isinstance(func, MethodType): + # Method attributes delegate to the original function. Its cached count + # includes self; binding derives an arity without overwriting that fact. + return count_parameters(func.__func__) - 1 try: return func._param_count except AttributeError: pass if isinstance(func, partial): - param_count = _count_parameters(func.func) - ( + param_count = count_parameters(func.func) - ( len(func.args) + len(func.keywords) ) - elif hasattr(func, "__self__"): - # Bound method - func = func.__func__ # type: ignore - param_count = _count_parameters(func) - 1 else: param_count = _count_parameters(func) try: func._param_count = param_count - except TypeError: + except (AttributeError, TypeError): pass return param_count diff --git a/tests/test_count_parameters.py b/tests/test_count_parameters.py index be3adda3b8..6df3fe91ce 100644 --- a/tests/test_count_parameters.py +++ b/tests/test_count_parameters.py @@ -1,5 +1,7 @@ from functools import partial +import pytest + from textual._callback import count_parameters @@ -55,3 +57,55 @@ def method(self, a, b, c, d): ... assert count_parameters(partial2) == 2 assert count_parameters(partial2) == 2 + + +@pytest.mark.parametrize("bound_first", [True, False]) +def test_binding_order_preserves_original_function_arity(bound_first) -> None: + class Owner: + def callback(self, old, value): ... + + owner = Owner() + first, second = ((owner.callback, Owner.callback) if bound_first + else (Owner.callback, owner.callback)) + assert count_parameters(first) == (2 if bound_first else 3) + assert count_parameters(second) == (3 if bound_first else 2) + assert count_parameters(partial(owner.callback, 1)) == 1 + assert count_parameters(partial(Owner.callback, owner, 1)) == 1 + assert count_parameters(Owner.callback) == 3 + assert count_parameters(owner.callback) == 2 + + +def test_uncacheable_callable_retains_signature_contract() -> None: + assert count_parameters(len) == 1 + assert count_parameters(partial(len)) == 1 + + +async def test_watchers_and_invocation_share_original_binding() -> None: + from textual._callback import invoke + from textual.app import App + from textual.reactive import reactive + from textual.widget import Widget + + observations = [] + + class Observed(Widget): + value = reactive(0) + + def watch_value(self, old, value): + observations.append((old, value)) + + # An unbound API consumer must not change native watcher dispatch. + assert count_parameters(Observed.watch_value) == 3 + owner = Observed() + app = App() + async with app.run_test() as pilot: + await app.mount(owner) + await pilot.pause() + owner.value = 5 + await pilot.pause() + assert observations[-1] == (0, 5) + # Bound watcher discovery must not truncate later unbound invocation. + await invoke(Observed.watch_value, owner, 7, 9) + assert observations[-1] == (7, 9) + await invoke(partial(owner.watch_value, 11), 13) + assert observations[-1] == (11, 13)