Skip to content

Commit 55cba60

Browse files
authored
Don't let a builtin name collision override a callable's real signature (#83)
`_robust_signature_of_callable` consulted the curated `sigs_for_sigless_builtin_name` / `sigs_for_type_name` tables *before* trying `inspect.signature`. Those tables are keyed by `__name__` (and by type name), which is only a sound key for the C-level builtins they were written for. Any callable that merely shared a builtin's name was therefore handed the builtin's signature instead of its own: f = mk_place_holder_func(['chunker', 'wfs'], name='map') inspect.signature(f) # (chunker, wfs) <- correct Sig(f) # (func, iterable, /, *iterables) <- wrong Downstream this grew phantom parameters: every meshed DAG node built from a function named `map` sprouted an extra `iterables` input. The name-before-signature order was introduced to fix `operator` instances (itemgetter/attrgetter/methodcaller), which in Python 3.12+ do have a signature but a useless generic `(*args, **kwargs)`. That part is legitimate, so rather than demote the tables to a pure fallback (which would change resolution for `print`, `partialmethod`, the operator classes and the dunder wrappers, whose `signature` succeeds but whose curated entries are intentionally richer), the tables are now skipped only for callables that declare a signature of their own -- Python-defined functions/methods, and anything carrying an explicit `__signature__`. Genuine builtins declare neither, so they are unaffected. Verified behaviour-neutral: resolution is byte-identical before and after across all ~170 callables in `builtins`, `functools` and `operator` plus operator instances. i2's own suite passes (699), and the 47-package dependents sweep has an identical pass/fail set before and after (32 pass, 14 fail, all pre-existing). Claude-Session: https://claude.ai/code/session_01Kug7UUbVeCQgruvNXUq63c
1 parent d97bb09 commit 55cba60

2 files changed

Lines changed: 111 additions & 10 deletions

File tree

i2/signatures.py

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
)
9898
from collections.abc import Callable, Iterable, Iterator, Mapping as MappingType
9999
from typing import KT, VT, T
100-
from types import FunctionType
100+
from types import FunctionType, MethodType
101101
from collections import defaultdict
102102
from operator import eq, attrgetter
103103

@@ -4311,6 +4311,47 @@ def decorator(targ_func):
43114311
# ############################################################################
43124312

43134313

4314+
#: Callable kinds that are defined in Python (as opposed to C-level builtins) and
4315+
#: therefore always carry authoritative signature information of their own.
4316+
PYTHON_DEFINED_CALLABLE_TYPES = (FunctionType, MethodType)
4317+
4318+
4319+
def _declares_own_signature(callable_obj: Callable) -> bool:
4320+
"""Whether ``callable_obj`` carries authoritative signature information of its own.
4321+
4322+
The ``sigs_for_sigless_builtin_name`` and ``sigs_for_type_name`` tables are keyed by
4323+
name, which is only a sound key for the C-level builtins they were written for. An
4324+
object that declares its own signature must never be overridden by a name collision.
4325+
4326+
A Python-defined function knows its own signature:
4327+
4328+
>>> def map(chunker, wfs): # shadows the ``map`` builtin
4329+
... ...
4330+
>>> _declares_own_signature(map)
4331+
True
4332+
4333+
So does any object carrying an explicit ``__signature__`` (which is how i2 itself
4334+
stamps signatures onto ``functools.partial`` objects and other wrappers):
4335+
4336+
>>> from functools import partial
4337+
>>> from inspect import signature
4338+
>>> p = partial(lambda a, b: None, 1)
4339+
>>> _declares_own_signature(p)
4340+
False
4341+
>>> p.__signature__ = signature(lambda chunker, wfs: None)
4342+
>>> _declares_own_signature(p)
4343+
True
4344+
4345+
Genuine builtins declare nothing, so the curated tables still apply to them:
4346+
4347+
>>> _declares_own_signature(print)
4348+
False
4349+
"""
4350+
return getattr(callable_obj, "__signature__", None) is not None or isinstance(
4351+
callable_obj, PYTHON_DEFINED_CALLABLE_TYPES
4352+
)
4353+
4354+
43144355
# TODO: Might want to monkey-patch inspect._signature_from_callable to use
43154356
# sigs_for_sigless_builtin_name
43164357
def _robust_signature_of_callable(callable_obj: Callable) -> Signature:
@@ -4330,16 +4371,30 @@ def _robust_signature_of_callable(callable_obj: Callable) -> Signature:
43304371
... ) # doesn't have one, so will return a blanket one
43314372
<Signature (*no_sig_args, **no_sig_kwargs)>
43324373
4374+
A callable that carries its own signature information is never overridden by the
4375+
curated tables, even if its ``__name__`` happens to collide with a builtin's:
4376+
4377+
>>> def map(chunker, wfs): # a Python function that shadows the ``map`` builtin
4378+
... ...
4379+
>>> _robust_signature_of_callable(map)
4380+
<Signature (chunker, wfs)>
4381+
43334382
"""
4334-
# First check if we have a custom signature for this type/object
4335-
# This is important for operator instances that might have generic signatures in Python 3.12+
4336-
obj_name = getattr(callable_obj, "__name__", None)
4337-
if obj_name in sigs_for_sigless_builtin_name:
4338-
return sigs_for_sigless_builtin_name[obj_name] or DFLT_SIGNATURE
4339-
4340-
type_name = getattr(type(callable_obj), "__name__", None)
4341-
if type_name in sigs_for_type_name:
4342-
return sigs_for_type_name[type_name] or DFLT_SIGNATURE
4383+
# The curated tables are keyed by *name*, which is only a sound key for the
4384+
# C-level builtins they were written for. Consulting them for a callable that
4385+
# knows its own signature would let a mere name collision (e.g. a Python function
4386+
# named ``map``) replace a correct signature with the builtin's one.
4387+
if not _declares_own_signature(callable_obj):
4388+
# Check for a curated signature for this object/type. This must precede
4389+
# ``signature`` because operator instances (itemgetter, attrgetter,
4390+
# methodcaller) do have a signature in Python 3.12+, but a useless generic one.
4391+
obj_name = getattr(callable_obj, "__name__", None)
4392+
if obj_name in sigs_for_sigless_builtin_name:
4393+
return sigs_for_sigless_builtin_name[obj_name] or DFLT_SIGNATURE
4394+
4395+
type_name = getattr(type(callable_obj), "__name__", None)
4396+
if type_name in sigs_for_type_name:
4397+
return sigs_for_type_name[type_name] or DFLT_SIGNATURE
43434398

43444399
# Try to get the signature normally
43454400
try:

i2/tests/test_signatures.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,3 +2400,49 @@ def _test_call(call, expected_output):
24002400
call()
24012401
else:
24022402
assert call() == expected_output
2403+
2404+
2405+
# ---------------------------------------------------------------------------------
2406+
# Regression: a name collision with a builtin must not override a real signature.
2407+
# `sigs_for_sigless_builtin_name` is keyed by __name__ alone, so consulting it before
2408+
# `inspect.signature` gave any callable named e.g. `map` the *builtin* map's signature,
2409+
# growing phantom parameters (it made meshed DAG nodes sprout an `iterables` input).
2410+
2411+
2412+
def test_builtin_name_collision_does_not_override_own_signature():
2413+
"""A callable named after a builtin keeps its own signature."""
2414+
2415+
# A plain Python function whose name shadows a builtin
2416+
def map(chunker, wfs): # noqa: A001 - shadowing is the point of the test
2417+
return chunker, wfs
2418+
2419+
assert str(Sig(map)) == "(chunker, wfs)"
2420+
assert str(_robust_signature_of_callable(map)) == "(chunker, wfs)"
2421+
2422+
# An object carrying an explicit __signature__ (how i2 stamps partials/wrappers)
2423+
placeholder = partial(lambda *a, **kw: None)
2424+
placeholder.__signature__ = signature(lambda chunker, wfs: None)
2425+
placeholder.__name__ = "map"
2426+
2427+
assert str(Sig(placeholder)) == "(chunker, wfs)"
2428+
assert str(_robust_signature_of_callable(placeholder)) == "(chunker, wfs)"
2429+
2430+
2431+
def test_sigless_builtins_still_get_their_curated_signatures():
2432+
"""The curated table must still serve the genuine builtins it was written for."""
2433+
# `map` itself has no introspectable signature, so the curated one must be used
2434+
with pytest.raises(ValueError):
2435+
signature(map)
2436+
assert str(Sig(map)) == str(sigs_for_sigless_builtin_name["map"])
2437+
2438+
# `print` has a curated signature that intentionally differs from the introspected
2439+
# one, and must keep winning
2440+
assert str(_robust_signature_of_callable(print)) == str(
2441+
sigs_for_sigless_builtin_name["print"]
2442+
)
2443+
2444+
# operator instances have a useless generic signature in 3.12+, so the curated
2445+
# per-type signature must keep taking precedence over `inspect.signature`
2446+
from operator import itemgetter
2447+
2448+
assert str(_robust_signature_of_callable(itemgetter(1))) != "(*args, **kwargs)"

0 commit comments

Comments
 (0)