Skip to content

Commit 6044930

Browse files
yushao2cdce8p
andauthored
Implemented new check consider-using-dict-items (#4445)
Co-authored-by: Marc Mueller <[email protected]>
1 parent 547ed55 commit 6044930

File tree

8 files changed

+236
-4
lines changed

8 files changed

+236
-4
lines changed

CONTRIBUTORS.txt

+2
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,5 @@ contributors:
484484
* Jiajunsu (victor): contributor
485485

486486
* Andrew Haigh (nelfin): contributor
487+
488+
* Pang Yu Shao (yushao2): contributor

ChangeLog

+5
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ modules are added.
3333

3434
* Fix raising false-positive ``no-member`` on abstract properties
3535

36+
* New checker ``consider-using-dict-items``. Emitted when iterating over dictionary keys and then
37+
indexing the same dictionary with the key within loop body.
38+
39+
Closes #3389
40+
3641

3742
What's New in Pylint 2.8.2?
3843
===========================

doc/whatsnew/2.9.rst

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ Summary -- Release highlights
1212
New checkers
1313
============
1414

15+
* ``consider-using-dict-items``: Emitted when iterating over dictionary keys and then
16+
indexing the same dictionary with the key within loop body.
17+
1518
Other Changes
1619
=============
1720

pylint/checkers/refactoring/recommendation_checker.py

+93-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
22
# For details: https://github.com/PyCQA/pylint/blob/master/LICENSE
3+
from typing import cast
34

45
import astroid
56

@@ -26,6 +27,14 @@ class RecommendationChecker(checkers.BaseChecker):
2627
"method. It is enough to just iterate through the dictionary itself, as "
2728
'in "for key in dictionary".',
2829
),
30+
"C0206": (
31+
"Consider iterating with .items()",
32+
"consider-using-dict-items",
33+
"Emitted when iterating over the keys of a dictionary and accessing the "
34+
"value by index lookup."
35+
"Both the key and value can be accessed by iterating using the .items() "
36+
"method of the dictionary instead.",
37+
),
2938
}
3039

3140
@staticmethod
@@ -53,8 +62,12 @@ def visit_call(self, node):
5362
if isinstance(node.parent, (astroid.For, astroid.Comprehension)):
5463
self.add_message("consider-iterating-dictionary", node=node)
5564

56-
@utils.check_messages("consider-using-enumerate")
57-
def visit_for(self, node):
65+
@utils.check_messages("consider-using-enumerate", "consider-using-dict-items")
66+
def visit_for(self, node: astroid.For) -> None:
67+
self._check_consider_using_enumerate(node)
68+
self._check_consider_using_dict_items(node)
69+
70+
def _check_consider_using_enumerate(self, node: astroid.For) -> None:
5871
"""Emit a convention whenever range and len are used for indexing."""
5972
# Verify that we have a `range([start], len(...), [stop])` call and
6073
# that the object which is iterated is used as a subscript in the
@@ -119,3 +132,81 @@ def visit_for(self, node):
119132
continue
120133
self.add_message("consider-using-enumerate", node=node)
121134
return
135+
136+
def _check_consider_using_dict_items(self, node: astroid.For) -> None:
137+
"""Add message when accessing dict values by index lookup."""
138+
# Verify that we have a .keys() call and
139+
# that the object which is iterated is used as a subscript in the
140+
# body of the for.
141+
142+
iterating_object_name = utils.get_iterating_dictionary_name(node)
143+
if iterating_object_name is None:
144+
return
145+
146+
# Verify that the body of the for loop uses a subscript
147+
# with the object that was iterated. This uses some heuristics
148+
# in order to make sure that the same object is used in the
149+
# for body.
150+
for child in node.body:
151+
for subscript in child.nodes_of_class(astroid.Subscript):
152+
subscript = cast(astroid.Subscript, subscript)
153+
154+
if not isinstance(subscript.value, (astroid.Name, astroid.Attribute)):
155+
continue
156+
157+
value = subscript.slice
158+
if isinstance(value, astroid.Index):
159+
value = value.value
160+
if (
161+
not isinstance(value, astroid.Name)
162+
or value.name != node.target.name
163+
or iterating_object_name != subscript.value.as_string()
164+
):
165+
continue
166+
last_definition_lineno = value.lookup(value.name)[1][-1].lineno
167+
if last_definition_lineno > node.lineno:
168+
# Ignore this subscript if it has been redefined after
169+
# the for loop. This checks for the line number using .lookup()
170+
# to get the line number where the iterating object was last
171+
# defined and compare that to the for loop's line number
172+
continue
173+
if (
174+
isinstance(subscript.parent, astroid.Assign)
175+
and subscript in subscript.parent.targets
176+
or isinstance(subscript.parent, astroid.AugAssign)
177+
and subscript == subscript.parent.target
178+
):
179+
# Ignore this subscript if it is the target of an assignment
180+
continue
181+
182+
self.add_message("consider-using-dict-items", node=node)
183+
return
184+
185+
@utils.check_messages("consider-using-dict-items")
186+
def visit_comprehension(self, node: astroid.Comprehension) -> None:
187+
iterating_object_name = utils.get_iterating_dictionary_name(node)
188+
if iterating_object_name is None:
189+
return
190+
191+
children = list(node.parent.get_children())
192+
if node.ifs:
193+
children.extend(node.ifs)
194+
for child in children:
195+
for subscript in child.nodes_of_class(astroid.Subscript):
196+
subscript = cast(astroid.Subscript, subscript)
197+
198+
if not isinstance(subscript.value, (astroid.Name, astroid.Attribute)):
199+
continue
200+
201+
value = subscript.slice
202+
if isinstance(value, astroid.Index):
203+
value = value.value
204+
if (
205+
not isinstance(value, astroid.Name)
206+
or value.name != node.target.name
207+
or iterating_object_name != subscript.value.as_string()
208+
):
209+
continue
210+
211+
self.add_message("consider-using-dict-items", node=node)
212+
return

pylint/checkers/typecheck.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1439,8 +1439,8 @@ def visit_call(self, node):
14391439
args=(display_name, callable_name),
14401440
)
14411441

1442-
for name in kwparams:
1443-
defval, assigned = kwparams[name]
1442+
for name, val in kwparams.items():
1443+
defval, assigned = val
14441444
if (
14451445
defval is None
14461446
and not assigned

pylint/checkers/utils.py

+30
Original file line numberDiff line numberDiff line change
@@ -1492,3 +1492,33 @@ def is_assign_name_annotated_with(node: astroid.AssignName, typing_name: str) ->
14921492
):
14931493
return True
14941494
return False
1495+
1496+
1497+
def get_iterating_dictionary_name(
1498+
node: Union[astroid.For, astroid.Comprehension]
1499+
) -> Optional[str]:
1500+
"""Get the name of the dictionary which keys are being iterated over on
1501+
a `astroid.For` or `astroid.Comprehension` node.
1502+
1503+
If the iterating object is not either the keys method of a dictionary
1504+
or a dictionary itself, this returns None.
1505+
"""
1506+
# Is it a proper keys call?
1507+
if (
1508+
isinstance(node.iter, astroid.Call)
1509+
and isinstance(node.iter.func, astroid.Attribute)
1510+
and node.iter.func.attrname == "keys"
1511+
):
1512+
inferred = safe_infer(node.iter.func)
1513+
if not isinstance(inferred, astroid.BoundMethod):
1514+
return None
1515+
return node.iter.as_string().rpartition(".keys")[0]
1516+
1517+
# Is it a dictionary?
1518+
if isinstance(node.iter, (astroid.Name, astroid.Attribute)):
1519+
inferred = safe_infer(node.iter)
1520+
if not isinstance(inferred, astroid.Dict):
1521+
return None
1522+
return node.iter.as_string()
1523+
1524+
return None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Emit a message for iteration through dict keys and subscripting dict with key."""
2+
# pylint: disable=line-too-long,missing-docstring,unsubscriptable-object,too-few-public-methods
3+
4+
def bad():
5+
a_dict = {1: 1, 2: 2, 3: 3}
6+
for k in a_dict: # [consider-using-dict-items]
7+
print(a_dict[k])
8+
another_dict = dict()
9+
for k in another_dict: # [consider-using-dict-items]
10+
print(another_dict[k])
11+
12+
13+
def good():
14+
a_dict = {1: 1, 2: 2, 3: 3}
15+
for k in a_dict:
16+
print(k)
17+
18+
out_of_scope_dict = dict()
19+
20+
def another_bad():
21+
for k in out_of_scope_dict: # [consider-using-dict-items]
22+
print(out_of_scope_dict[k])
23+
24+
def another_good():
25+
for k in out_of_scope_dict:
26+
k = 1
27+
k = 2
28+
k = 3
29+
print(out_of_scope_dict[k])
30+
31+
32+
b_dict = {}
33+
for k2 in b_dict: # Should not emit warning, key access necessary
34+
b_dict[k2] = 2
35+
36+
for k2 in b_dict: # Should not emit warning, key access necessary (AugAssign)
37+
b_dict[k2] += 2
38+
39+
# Warning should be emitted in this case
40+
for k6 in b_dict: # [consider-using-dict-items]
41+
val = b_dict[k6]
42+
b_dict[k6] = 2
43+
44+
for k3 in b_dict: # [consider-using-dict-items]
45+
val = b_dict[k3]
46+
47+
for k4 in b_dict.keys(): # [consider-iterating-dictionary,consider-using-dict-items]
48+
val = b_dict[k4]
49+
50+
class Foo:
51+
c_dict = {}
52+
53+
# Should emit warning when iterating over a dict attribute of a class
54+
for k5 in Foo.c_dict: # [consider-using-dict-items]
55+
val = Foo.c_dict[k5]
56+
57+
c_dict = {}
58+
59+
# Should NOT emit warning whey key used to access a different dict
60+
for k5 in Foo.c_dict: # This is fine
61+
val = b_dict[k5]
62+
63+
for k5 in Foo.c_dict: # This is fine
64+
val = c_dict[k5]
65+
66+
# Should emit warning within a list/dict comprehension
67+
val = {k9: b_dict[k9] for k9 in b_dict} # [consider-using-dict-items]
68+
val = [(k7, b_dict[k7]) for k7 in b_dict] # [consider-using-dict-items]
69+
70+
# Should emit warning even when using dict attribute of a class within comprehension
71+
val = [(k7, Foo.c_dict[k7]) for k7 in Foo.c_dict] # [consider-using-dict-items]
72+
val = any(True for k8 in Foo.c_dict if Foo.c_dict[k8]) # [consider-using-dict-items]
73+
74+
# Should emit warning when dict access done in ``if`` portion of comprehension
75+
val = any(True for k8 in b_dict if b_dict[k8]) # [consider-using-dict-items]
76+
77+
# Should NOT emit warning whey key used to access a different dict
78+
val = [(k7, b_dict[k7]) for k7 in Foo.c_dict]
79+
val = any(True for k8 in Foo.c_dict if b_dict[k8])
80+
81+
# Should NOT emit warning, essentially same check as above
82+
val = [(k7, c_dict[k7]) for k7 in Foo.c_dict]
83+
val = any(True for k8 in Foo.c_dict if c_dict[k8])
84+
85+
# Should emit warning, using .keys() of Foo.c_dict
86+
val = any(True for k8 in Foo.c_dict.keys() if Foo.c_dict[k8]) # [consider-iterating-dictionary,consider-using-dict-items]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
consider-using-dict-items:6:4:bad:Consider iterating with .items()
2+
consider-using-dict-items:9:4:bad:Consider iterating with .items()
3+
consider-using-dict-items:21:4:another_bad:Consider iterating with .items()
4+
consider-using-dict-items:40:0::Consider iterating with .items()
5+
consider-using-dict-items:44:0::Consider iterating with .items()
6+
consider-iterating-dictionary:47:10::Consider iterating the dictionary directly instead of calling .keys()
7+
consider-using-dict-items:47:0::Consider iterating with .items()
8+
consider-using-dict-items:54:0::Consider iterating with .items()
9+
consider-using-dict-items:67:0::Consider iterating with .items()
10+
consider-using-dict-items:68:0::Consider iterating with .items()
11+
consider-using-dict-items:71:0::Consider iterating with .items()
12+
consider-using-dict-items:72:0::Consider iterating with .items()
13+
consider-using-dict-items:75:0::Consider iterating with .items()
14+
consider-iterating-dictionary:86:25::Consider iterating the dictionary directly instead of calling .keys()
15+
consider-using-dict-items:86:0::Consider iterating with .items()

0 commit comments

Comments
 (0)