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
20 changes: 20 additions & 0 deletions evidence/callback-binding-arity-20261004/SANITY.json
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions evidence/callback-binding-arity-20261004/SCOPE.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions evidence/callback-binding-arity-20261004/owner-consumers.json
Original file line number Diff line number Diff line change
@@ -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."
}
15 changes: 8 additions & 7 deletions src/textual/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
54 changes: 54 additions & 0 deletions tests/test_count_parameters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from functools import partial

import pytest

from textual._callback import count_parameters


Expand Down Expand Up @@ -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)
Loading