Skip to content

Commit 3f09116

Browse files
committed
Add partial support for unused import removal
1 parent f704ca3 commit 3f09116

File tree

5 files changed

+104
-3
lines changed

5 files changed

+104
-3
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ code base is implemented in.
173173
- [ ] Check if file is still valid/parsable after automatic fixing, if not: halt the change and report error.
174174

175175
## Release notes
176+
- v2.3.1:
177+
- Added support for automatic removal of imports.
176178
- v2.3.0:
177179
- Add `--dry` option.
178180
- Update error codes to use DCXX format instead of DCXXX.

deadcode/actions/remove_file_parts_from_content.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ def remove_as_from_end(line: str) -> str:
3434
return line
3535

3636

37+
def remove_comma_from_begining(line: str) -> str:
38+
if not line.lstrip().startswith(','):
39+
return line
40+
41+
return line.lstrip()[1:].lstrip()
42+
43+
3744
def remove_file_parts_from_content(content_lines: List[str], unused_file_parts: List[Part]) -> List[str]:
3845
""" """
3946
# How should move through the lines of content?
@@ -63,10 +70,14 @@ def remove_file_parts_from_content(content_lines: List[str], unused_file_parts:
6370
indentation_of_first_removed_line = get_indentation(line)
6471

6572
if from_line == to_line:
73+
# TODO: this check is a workaround for an assignment expression.
74+
# Clean-ups have to be applied based on removable expression type.
6675
if (line[:from_col] + line[to_col:]).strip().startswith('='):
6776
line = line[:from_col]
6877
else:
69-
line = remove_as_from_end(line[:from_col]) + line[to_col:]
78+
# TODO: should apply `as` removal rule only for particular expression type only
79+
# as well as comma removal.
80+
line = remove_as_from_end(line[:from_col]) + remove_comma_from_begining(line[to_col:])
7081

7182
unused_part_index += 1
7283

@@ -87,6 +98,12 @@ def remove_file_parts_from_content(content_lines: List[str], unused_file_parts:
8798
# Is it last line, from the block, which have to be ignored?
8899
elif current_lineno == to_line:
89100
line = line[to_col:]
101+
102+
# Remove trailing comma, if we have removed something.
103+
# TODO: this should be applied only for specific expression types only.
104+
if line.lstrip().startswith(","):
105+
line = line.lstrip()[1:].lstrip()
106+
90107
unused_part_index += 1
91108
# TODO: Add tests for case, when comments are added at the start or end of the removed block
92109
if line.strip() and not line.startswith('#'):

deadcode/visitor/code_item.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class CodeItem: # TODO: This should also be a dataclass, because hash and tuple
4646
'type_',
4747
'filename',
4848
'code_parts',
49+
'expression_code_parts',
4950
'scope',
5051
'inherits_from',
5152
'name_line',
@@ -66,6 +67,7 @@ def __init__(
6667
# first_column: int = 0,
6768
# last_column: Optional[int] = None,
6869
code_parts: Optional[List[Part]] = None, # TODO: I should use a dataclass instead of a tuple for Part.
70+
expression_code_parts: Optional[List[Part]] = None,
6971
scope: Optional[str] = None,
7072
inherits_from: Optional[List[str]] = None,
7173
name_line: Optional[int] = None,
@@ -85,6 +87,11 @@ def __init__(
8587
else:
8688
self.code_parts = code_parts
8789

90+
if expression_code_parts is None:
91+
self.expression_code_parts = []
92+
else:
93+
self.expression_code_parts = expression_code_parts
94+
8895
# if first_lineno is not None:
8996
# pass
9097

deadcode/visitor/dead_code_visitor.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ def _add_aliases(self, node: Union[ast.Import, ast.ImportFrom]) -> None:
187187
self._define(
188188
self.defined_imports,
189189
alias or name,
190-
node,
190+
first_node=name_and_alias,
191+
expression_node=node,
191192
ignore=_ignore_import,
192193
)
193194
if alias is not None:
@@ -234,6 +235,7 @@ def _define(
234235
name: str,
235236
first_node: ast.AST,
236237
last_node: Optional[ast.AST] = None,
238+
expression_node: Optional[ast.AST] = None,
237239
message: str = '',
238240
ignore: Optional[Callable[[Path, str], bool]] = None,
239241
) -> None:
@@ -255,8 +257,8 @@ def ignored(lineno: int, type_: UnusedCodeType) -> bool:
255257

256258
last_node = last_node or first_node
257259
type_: UnusedCodeType = collection.type_ # type: ignore
258-
first_lineno = lines.get_first_line_number(first_node)
259260

261+
first_lineno = lines.get_first_line_number(first_node)
260262
last_lineno = lines.get_last_line_number(last_node)
261263

262264
inherits_from = getattr(first_node, 'inherits_from', None)
@@ -271,6 +273,20 @@ def ignored(lineno: int, type_: UnusedCodeType) -> bool:
271273
message=message,
272274
inherits_from=inherits_from,
273275
)
276+
277+
# TODO: Remaining import expressions has to be removed, when all imported
278+
# names get removed. Pass whole import expression parts and evaluate if its still
279+
# empty after making code adjustments.
280+
# This feature is not yet implemented.
281+
if expression_node:
282+
expression_first_lineno = lines.get_first_line_number(expression_node)
283+
expression_last_lineno = lines.get_last_line_number(expression_node)
284+
code_item.expression_code_parts = [Part(
285+
expression_first_lineno,
286+
expression_last_lineno,
287+
expression_node.col_offset,
288+
expression_node.end_col_offset or 0)]
289+
274290
self.scopes.add(code_item)
275291

276292
if ignored(first_lineno, type_=type_):

tests/fix/test_unused_imports.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Test unused import detection and removal.
3+
"""
4+
5+
from unittest import skip
6+
7+
from deadcode.cli import main
8+
from deadcode.utils.base_test_case import BaseTestCase
9+
from deadcode.utils.fix_indent import fix_indent
10+
11+
12+
class TestAssignmentExpressionRemoval(BaseTestCase):
13+
def test_variable(self):
14+
self.files = {
15+
'file2.py': """
16+
from file1 import (
17+
foo,
18+
bar,
19+
xyz
20+
)
21+
22+
from file1 import foo
23+
24+
def fn():
25+
pass
26+
27+
fn()
28+
"""
29+
}
30+
31+
unused_names = main('file2.py --no-color --fix -v'.split())
32+
33+
self.assertEqual(
34+
unused_names,
35+
fix_indent("""\
36+
file2.py:2:4: DC07 Import `foo` is never used
37+
file2.py:3:4: DC07 Import `bar` is never used
38+
file2.py:4:4: DC07 Import `xyz` is never used
39+
file2.py:7:18: DC07 Import `foo` is never used
40+
41+
Removed 4 unused code items!""")
42+
)
43+
44+
# TODO: empty imports statements should be removed as well.
45+
self.assertFiles(
46+
{
47+
'file2.py': """
48+
from file1 import (
49+
)
50+
from file1 import
51+
52+
53+
def fn():
54+
pass
55+
56+
fn()
57+
"""
58+
}
59+
)

0 commit comments

Comments
 (0)