Skip to content

Commit fd9c6f1

Browse files
committed
Create a NamedTuple instance for NamedTuples
1 parent af718fe commit fd9c6f1

File tree

9 files changed

+71
-42
lines changed

9 files changed

+71
-42
lines changed

ChangeLog

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ Release date: TBA
6060
* On Python versions >= 3.9, ``astroid`` now understands subscripting
6161
builtin classes such as ``enumerate`` or ``staticmethod``.
6262

63+
* Instances of NamedTuples both from typing and collections will now be cast to a
64+
NamedTuple instance. This instance proxies the definition of that NamedTuple.
65+
6366
* Fixed inference of ``Enums`` when they are imported under an alias.
6467

6568
Closes PyCQA/pylint#5776

astroid/bases.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,7 @@ def __repr__(self):
621621

622622
def __str__(self):
623623
return f"AsyncGenerator({self._proxied.name})"
624+
625+
626+
class NamedTuple(BaseInstance):
627+
"""Special node representing a NamedTuple instance"""

astroid/brain/brain_namedtuple_enum.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -186,21 +186,30 @@ def infer_named_tuple(
186186
node: nodes.Call, context: InferenceContext | None = None
187187
) -> Iterator[nodes.ClassDef]:
188188
"""Specific inference function for namedtuple Call node"""
189-
tuple_base_name = nodes.Name(name="tuple", parent=node.root())
190-
class_node, name, attributes = infer_func_form(
191-
node, tuple_base_name, context=context
192-
)
189+
# Infer which type of NamedTuple we're dealing with (typing or collections)
190+
inferred_namedtuple_call = next(node.func.infer())
191+
base_names = [
192+
nodes.Name(name="tuple", parent=node.root()),
193+
nodes.Name(
194+
name=inferred_namedtuple_call.name,
195+
parent=inferred_namedtuple_call.root(),
196+
),
197+
]
198+
199+
class_node, name, attributes = infer_func_form(node, base_names, context=context)
193200
call_site = arguments.CallSite.from_call(node, context=context)
194-
node = extract_node("import collections; collections.namedtuple")
195-
try:
196201

197-
func = next(node.infer())
198-
except StopIteration as e:
199-
raise InferenceError(node=node) from e
200202
try:
201-
rename = next(call_site.infer_argument(func, "rename", context)).bool_value()
203+
rename = next(
204+
call_site.infer_argument(inferred_namedtuple_call, "rename", context)
205+
).bool_value()
202206
except (InferenceError, StopIteration):
203207
rename = False
208+
# If inferred_namedtuple_call is the ClassDef of typing.NamedTuple
209+
# infer_argument will raise AttributeError
210+
# TODO: See if this exception can be prevented
211+
except AttributeError:
212+
rename = False
204213

205214
try:
206215
attributes = _check_namedtuple_attributes(name, attributes, rename)
@@ -331,7 +340,7 @@ def value(self):
331340
__members__ = ['']
332341
"""
333342
)
334-
class_node = infer_func_form(node, enum_meta, context=context, enum=True)[0]
343+
class_node = infer_func_form(node, [enum_meta], context=context, enum=True)[0]
335344
return iter([class_node.instantiate_class()])
336345

337346

@@ -509,9 +518,11 @@ def infer_typing_namedtuple_function(node, context=None):
509518
def infer_typing_namedtuple(
510519
node: nodes.Call, context: InferenceContext | None = None
511520
) -> Iterator[nodes.ClassDef]:
512-
"""Infer a typing.NamedTuple(...) call."""
513-
# This is essentially a namedtuple with different arguments
514-
# so we extract the args and infer a named tuple.
521+
"""Infer a typing.NamedTuple(...) call.
522+
523+
We do some premature checking of the node to see if we don't run into any unexpected
524+
values.
525+
"""
515526
try:
516527
func = next(node.func.infer())
517528
except (InferenceError, StopIteration) as exc:

astroid/const.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
import sys
77
from pathlib import Path
88

9+
if sys.version_info >= (3, 8):
10+
from typing import Final
11+
else:
12+
from typing_extensions import Final
13+
914
PY38 = sys.version_info[:2] == (3, 8)
1015
PY38_PLUS = sys.version_info >= (3, 8)
1116
PY39_PLUS = sys.version_info >= (3, 9)
@@ -33,3 +38,6 @@ class Context(enum.Enum):
3338

3439
ASTROID_INSTALL_DIRECTORY = Path(__file__).parent
3540
BRAIN_MODULES_DIRECTORY = ASTROID_INSTALL_DIRECTORY / "brain"
41+
42+
NAMEDTUPLE_BASENAMES: Final[frozenset[str]] = frozenset(("namedtuple", "NamedTuple"))
43+
"""Const used to identify namedtuples in the basenames of subclasses"""

astroid/filter_statements.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,27 +78,27 @@ def _filter_stmts(base_node: nodes.NodeNG, stmts, frame, offset):
7878
#
7979
# def test(b=1):
8080
# ...
81+
#
82+
# If the frame is already a Module we don't need to go up anymore
8183
if (
82-
base_node.parent
84+
not isinstance(myframe, nodes.Module)
85+
and base_node.parent
8386
and base_node.statement(future=True) is myframe
8487
and myframe.parent
8588
):
8689
myframe = myframe.parent.frame()
8790

91+
# We can use line filtering if we are in the same frame.
92+
# mylineno is 0 by default to skip if we can't determine lineno
93+
# or if we are at the module level. lineno information is (for example)
94+
# missing for nodes inserted for living objects.
95+
mylineno = 0
8896
mystmt: nodes.Statement | None = None
89-
if base_node.parent:
97+
if base_node.parent and not isinstance(base_node.parent, nodes.Module):
9098
mystmt = base_node.statement(future=True)
91-
92-
# line filtering if we are in the same frame
93-
#
94-
# take care node may be missing lineno information (this is the case for
95-
# nodes inserted for living objects)
96-
if myframe is frame and mystmt and mystmt.fromlineno is not None:
97-
assert mystmt.fromlineno is not None, mystmt
98-
mylineno = mystmt.fromlineno + offset
99-
else:
100-
# disabling lineno filtering
101-
mylineno = 0
99+
if myframe is frame and mystmt.fromlineno is not None:
100+
assert mystmt.fromlineno is not None, mystmt
101+
mylineno = mystmt.fromlineno + offset
102102

103103
_stmts = []
104104
_stmt_parents = []

astroid/nodes/scoped_nodes/scoped_nodes.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
from astroid import bases
2222
from astroid import decorators as decorators_mod
23-
from astroid import util
24-
from astroid.const import IS_PYPY, PY38, PY38_PLUS, PY39_PLUS
23+
from astroid import mixins, util
24+
from astroid.const import IS_PYPY, NAMEDTUPLE_BASENAMES, PY38, PY38_PLUS, PY39_PLUS
2525
from astroid.context import (
2626
CallContext,
2727
InferenceContext,
@@ -2521,6 +2521,8 @@ def instantiate_class(self):
25212521
return objects.ExceptionInstance(self)
25222522
except MroError:
25232523
pass
2524+
if any(i in NAMEDTUPLE_BASENAMES for i in self.basenames):
2525+
return bases.NamedTuple(self)
25242526
return bases.Instance(self)
25252527

25262528
def getattr(self, name, context=None, class_context=True):

tests/unittest_brain.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,7 +1502,8 @@ class X(NamedTuple("X", [("a", int), ("b", str), ("c", bytes)])):
15021502
"""
15031503
)
15041504
self.assertEqual(
1505-
[anc.name for anc in klass.ancestors()], ["X", "tuple", "object"]
1505+
[anc.name for anc in klass.ancestors()],
1506+
["X", "tuple", "object", "NamedTuple"],
15061507
)
15071508
for anc in klass.ancestors():
15081509
self.assertFalse(anc.parent is None)
@@ -1611,7 +1612,7 @@ class Example(NamedTuple):
16111612
"""
16121613
)
16131614
inferred = next(result.infer())
1614-
self.assertIsInstance(inferred, astroid.Instance)
1615+
self.assertIsInstance(inferred, bases.NamedTuple)
16151616

16161617
class_attr = inferred.getattr("CLASS_ATTR")[0]
16171618
self.assertIsInstance(class_attr, astroid.AssignName)
@@ -1784,7 +1785,7 @@ def test_typing_namedtuple_dont_crash_on_no_fields(self) -> None:
17841785
"""
17851786
)
17861787
inferred = next(node.infer())
1787-
self.assertIsInstance(inferred, astroid.Instance)
1788+
self.assertIsInstance(inferred, bases.NamedTuple)
17881789

17891790
@test_utils.require_version("3.8")
17901791
def test_typed_dict(self):
@@ -3131,7 +3132,7 @@ def test_http_client_brain() -> None:
31313132
"""
31323133
)
31333134
inferred = next(node.infer())
3134-
assert isinstance(inferred, astroid.Instance)
3135+
assert isinstance(inferred, bases.NamedTuple)
31353136

31363137

31373138
def test_http_status_brain() -> None:

tests/unittest_inference.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import pytest
1919

20-
from astroid import Slice, arguments
20+
from astroid import Slice, arguments, bases
2121
from astroid import decorators as decoratorsmod
2222
from astroid import helpers, nodes, objects, test_utils, util
2323
from astroid.arguments import CallSite
@@ -2193,8 +2193,8 @@ def collections(self):
21932193
21942194
"""
21952195
ast = parse(code, __name__)
2196-
bases = ast["Second"].bases[0]
2197-
inferred = next(bases.infer())
2196+
base_classes = ast["Second"].bases[0]
2197+
inferred = next(base_classes.infer())
21982198
self.assertTrue(inferred)
21992199
self.assertIsInstance(inferred, nodes.ClassDef)
22002200
self.assertEqual(inferred.qname(), "collections.Counter")
@@ -6249,7 +6249,7 @@ def test_inferaugassign_picking_parent_instead_of_stmt() -> None:
62496249
# as a string.
62506250
node = extract_node(code)
62516251
inferred = next(node.infer())
6252-
assert isinstance(inferred, Instance)
6252+
assert isinstance(inferred, bases.NamedTuple)
62536253
assert inferred.name == "SomeClass"
62546254

62556255

tests/unittest_object_model.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
import astroid
11-
from astroid import builder, nodes, objects, test_utils, util
11+
from astroid import bases, builder, nodes, objects, test_utils, util
1212
from astroid.const import PY311_PLUS
1313
from astroid.exceptions import InferenceError
1414

@@ -203,9 +203,9 @@ class C(A): pass
203203
called_mro = next(ast_nodes[5].infer())
204204
self.assertEqual(called_mro.elts, mro.elts)
205205

206-
bases = next(ast_nodes[6].infer())
207-
self.assertIsInstance(bases, astroid.Tuple)
208-
self.assertEqual([cls.name for cls in bases.elts], ["object"])
206+
bases_classes = next(ast_nodes[6].infer())
207+
self.assertIsInstance(bases_classes, astroid.Tuple)
208+
self.assertEqual([cls.name for cls in bases_classes.elts], ["object"])
209209

210210
cls = next(ast_nodes[7].infer())
211211
self.assertIsInstance(cls, astroid.ClassDef)
@@ -694,7 +694,7 @@ def foo():
694694
self.assertIsInstance(wrapped, astroid.FunctionDef)
695695
self.assertEqual(wrapped.name, "foo")
696696
cache_info = next(ast_nodes[2].infer())
697-
self.assertIsInstance(cache_info, astroid.Instance)
697+
self.assertIsInstance(cache_info, bases.NamedTuple)
698698

699699

700700
if __name__ == "__main__":

0 commit comments

Comments
 (0)