Skip to content
39 changes: 39 additions & 0 deletions astroid/brain/brain_builtin_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,43 @@ def _builtin_filter_predicate(node, builtin_name) -> bool:
return False


def _is_from_builtins_import(stmt: nodes.NodeNG, name: str) -> bool:
"""True if *stmt* is ``from builtins import ...`` binding *name* to ``builtins.name``.

The binding has to be the builtin of the same name. An alias makes it a
different one: ``from builtins import int as str`` leaves ``str`` calling
``int``.
"""
if not isinstance(stmt, nodes.ImportFrom) or stmt.modname != "builtins":
return False
try:
return stmt.real_name(name) == name
except AttributeInferenceError:
return False


def _is_builtin_call(node: nodes.Call) -> bool:
"""True if the callable on *node* actually comes from builtins.

The filter only matches the name text, so a parameter called ``type`` still
picks the transform. Check where the name resolves before trusting it.
``lookup`` handles the ordering rules for us: later assignments in the same
scope do not count, and a name in a default value resolves in the enclosing
scope.
"""
func = node.func
if isinstance(func, nodes.Attribute):
# dict.fromkeys: what matters is where ``dict`` comes from.
func = func.expr
if not isinstance(func, nodes.Name): # pragma: no cover
# The predicate only lets through a Name or ``dict.fromkeys``.
return False
frame, stmts = func.lookup(func.name)
if isinstance(frame, nodes.Module) and frame.qname() == "builtins":
return True
return any(_is_from_builtins_import(stmt, func.name) for stmt in stmts)


def register_builtin_transform(
manager: AstroidManager, transform, builtin_name
) -> None:
Expand All @@ -234,6 +271,8 @@ def register_builtin_transform(
def _transform_wrapper(
node: nodes.Call, context: InferenceContext | None = None
) -> Iterator:
if not _is_builtin_call(node):
raise UseInferenceDefault
Comment on lines +274 to +275

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prevents the use of _inference_tip_cached later, not sure if it's worth resturcturing for.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair point. I left the check in the tip so the cheap name filter stays as-is. Pulling it into the predicate (or otherwise making misses cacheable) felt like a bigger reshuffle than this bug needs. Easy to revisit if it shows up hot.

result = transform(node, context=context)
if result:
if not result.parent:
Expand Down
65 changes: 60 additions & 5 deletions astroid/nodes/scoped_nodes/scoped_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,58 @@ def _infer_decorator_callchain(node):
return None


def _signature_part(
func: Lambda | FunctionDef, node: NodeNG
) -> Literal["default", "annotation"] | None:
"""Which part of *func*'s signature *node* sits in, if any.

Defaults and annotations are evaluated where the function is defined, not
inside it, so a name in one must not resolve to the parameters it sits next
to. That goes for a name nested in the value too, not just the value
itself, which is why this cannot be a plain identity test.

The two are told apart because only annotations see the type parameters:
``def f[T](x: T = T)`` is fine on the annotation and a ``NameError`` on the
default.

Walk up from *node* rather than down from each value: the walk stops at
the arguments (found) or at the function they belong to (not found), so
this stays cheap for the common case of a name in the body.
"""
args = func.args
child = node
parent = child.parent
while parent is not None:
if parent is args:
if any(
child is default
for default in itertools.chain(
args.defaults or (), args.kw_defaults or ()
)
):
return "default"
if any(
child is annotation
for annotation in itertools.chain(
args.annotations or (),
args.posonlyargs_annotations or (),
args.kwonlyargs_annotations or (),
(args.varargannotation, args.kwargannotation),
)
):
return "annotation"
return None
if parent is func:
# Reached the function or lambda without going through its
# arguments, so the only signature value left is the return
# annotation; anything else lives in the body.
if isinstance(func, FunctionDef) and child is func.returns:
return "annotation"
return None
child, parent = parent, parent.parent
return None


class Lambda(_base_nodes.FilterStmtsBaseNode, LocalsDictNodeNG):
"""Class representing an :class:`ast.Lambda` node.

Expand Down Expand Up @@ -1002,9 +1054,7 @@ def scope_lookup(
given name according to the scope where it has been found (locals,
globals or builtin).
"""
if (self.args.defaults and node in self.args.defaults) or (
self.args.kw_defaults and node in self.args.kw_defaults
):
if _signature_part(self, node) is not None:
if not self.parent:
raise ParentMissingError(target=self)
frame = self.parent.frame()
Expand Down Expand Up @@ -1672,9 +1722,14 @@ def scope_lookup(
if self.parent and isinstance(frame := self.parent.frame(), ClassDef):
return self, [frame]

if (self.args.defaults and node in self.args.defaults) or (
self.args.kw_defaults and node in self.args.kw_defaults
part = _signature_part(self, node)
# An annotation is evaluated in the scope holding the type parameters,
# which sits between the function and the scope it is defined in.
if part == "annotation" and any(
type_param.name.name == name for type_param in self.type_params
):
part = None
if part is not None:
if not self.parent:
raise ParentMissingError(target=self)
frame = self.parent.frame()
Expand Down
7 changes: 7 additions & 0 deletions doc/whatsnew/fragments/3211.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
The builtin inference tips no longer fire on a name that shadows the builtin
they are registered for. They were selected on the identifier alone, so a
parameter named ``type`` or a function named ``len`` was still inferred as a
call to the builtin.

Refs #3211
Closes pylint-dev/pylint#10994
19 changes: 19 additions & 0 deletions tests/brain/test_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ def test_type_subscript(self):
meth_inf = val_inf.getattr("__class_getitem__")[0]
self.assertIsInstance(meth_inf, nodes.FunctionDef)

@pytest.mark.xfail(
reason="infer_type_sub looks 'type' up from the scope, so a parameter hides it"
)
def test_type_subscript_next_to_a_parameter_named_type(self):
"""A parameter called ``type`` does not reach the annotation next to it.

The annotation is evaluated where the function is defined, so ``type``
there is still the builtin and ``type[int]`` still works.
"""
src = builder.extract_node("""
def feed(type: str) -> type[int]:
return int
""")
val_inf = src.returns.value.inferred()[0]
self.assertIsInstance(val_inf, nodes.ClassDef)
self.assertEqual(val_inf.name, "type")
meth_inf = val_inf.getattr("__class_getitem__")[0]
self.assertIsInstance(meth_inf, nodes.FunctionDef)

def test_invalid_type_subscript(self):
"""
Check that a type (str for example) that inherits
Expand Down
Loading