From e90bde509c074607504bac50d3c9c1c5ebce9d40 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 23:09:05 -0400 Subject: [PATCH 1/4] docs: declare callback binding and raw arity scope --- .../callback-binding-arity-20261004/SCOPE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 evidence/callback-binding-arity-20261004/SCOPE.md diff --git a/evidence/callback-binding-arity-20261004/SCOPE.md b/evidence/callback-binding-arity-20261004/SCOPE.md new file mode 100644 index 0000000000..cff43887ee --- /dev/null +++ b/evidence/callback-binding-arity-20261004/SCOPE.md @@ -0,0 +1,17 @@ +# 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 will keep the existing function hint as raw +arity, derive binding before reading it, and derive partial arity through the +same original owner. No new cache, callable wrapper or state owner is needed. +Source reasoning and complete consumer migration precede the final affected +controls. 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. From 4a8304f355b0cf8be79cf82606a943639904ae2f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 23:09:58 -0400 Subject: [PATCH 2/4] fix: keep callback arity on its original callable owner --- src/textual/_callback.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 From 405b8c0b63e9a44efe6620d8cfd6af0a6b0771dd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 23:12:02 -0400 Subject: [PATCH 3/4] test: qualify callback binding across watcher and invocation consumers --- .../SANITY.json | 20 ++++ .../callback-binding-arity-20261004/SCOPE.md | 18 +++- .../owner-consumers.json | 95 +++++++++++++++++++ tests/test_count_parameters.py | 54 +++++++++++ 4 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 evidence/callback-binding-arity-20261004/SANITY.json create mode 100644 evidence/callback-binding-arity-20261004/owner-consumers.json 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 index cff43887ee..c3f76b7b10 100644 --- a/evidence/callback-binding-arity-20261004/SCOPE.md +++ b/evidence/callback-binding-arity-20261004/SCOPE.md @@ -6,12 +6,22 @@ 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 will keep the existing function hint as raw +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. No new cache, callable wrapper or state owner is needed. -Source reasoning and complete consumer migration precede the final affected -controls. Native viewport projection PR63 stays frozen and independent. +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..27cc1995b7 --- /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#L187", + "limitation": "No measurement that reflection dominates a frame gap; repeated-bound-reflection claim withdrawn." +} 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) From 5a2d9b94b81f3883d3f77d0e2b8ceb2e5360b887 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 23:12:37 -0400 Subject: [PATCH 4/4] docs: link the original Python binding declaration --- evidence/callback-binding-arity-20261004/owner-consumers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evidence/callback-binding-arity-20261004/owner-consumers.json b/evidence/callback-binding-arity-20261004/owner-consumers.json index 27cc1995b7..c7a1527399 100644 --- a/evidence/callback-binding-arity-20261004/owner-consumers.json +++ b/evidence/callback-binding-arity-20261004/owner-consumers.json @@ -90,6 +90,6 @@ ] } ], - "python_binding_source": "https://github.com/python/cpython/blob/v3.14.2/Objects/classobject.c#L187", + "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." }