deadcode 2.5.0__py3-none-any.whl → 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. .venv310/bin/activate_this.py +59 -0
  2. .venv311/bin/activate_this.py +59 -0
  3. .venv314/bin/activate_this.py +59 -0
  4. .venv315/bin/activate_this.py +59 -0
  5. deadcode/__init__.py +1 -1
  6. deadcode/actions/find_python_filenames.py +2 -18
  7. deadcode/actions/find_unused_names.py +2 -2
  8. deadcode/actions/fix_or_show_unused_code.py +12 -9
  9. deadcode/actions/get_unused_import_parts.py +207 -0
  10. deadcode/actions/get_unused_names_error_message.py +15 -11
  11. deadcode/actions/merge_overlaping_file_parts.py +4 -5
  12. deadcode/actions/parse_abstract_syntax_tree.py +2 -1
  13. deadcode/actions/parse_arguments.py +35 -17
  14. deadcode/actions/remove_file_parts_from_content.py +33 -11
  15. deadcode/cli.py +40 -18
  16. deadcode/constants.py +7 -4
  17. deadcode/data_types.py +2 -1
  18. deadcode/utils/fix_indent.py +4 -12
  19. deadcode/utils/flatten_lists.py +4 -4
  20. deadcode/utils/nested_scopes.py +6 -8
  21. deadcode/utils/print_safely.py +29 -0
  22. deadcode/visitor/code_item.py +5 -62
  23. deadcode/visitor/dead_code_visitor.py +333 -98
  24. deadcode/visitor/ignore.py +42 -9
  25. deadcode/visitor/lines.py +2 -3
  26. deadcode/visitor/module_exports.py +80 -0
  27. deadcode/visitor/noqa.py +3 -4
  28. deadcode/visitor/utils.py +40 -17
  29. deadcode-2.6.0.dist-info/METADATA +382 -0
  30. deadcode-2.6.0.dist-info/RECORD +39 -0
  31. docs/conf.py +20 -0
  32. deadcode/utils/base_test_case.py +0 -88
  33. deadcode-2.5.0.dist-info/METADATA +0 -244
  34. deadcode-2.5.0.dist-info/RECORD +0 -32
  35. {deadcode-2.5.0.dist-info → deadcode-2.6.0.dist-info}/WHEEL +0 -0
  36. {deadcode-2.5.0.dist-info → deadcode-2.6.0.dist-info}/entry_points.txt +0 -0
  37. {deadcode-2.5.0.dist-info → deadcode-2.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2020-202x The virtualenv developers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ """
23
+ Activate virtualenv for current interpreter:
24
+
25
+ import runpy
26
+ runpy.run_path(this_file)
27
+
28
+ This can be used when you must use an existing Python interpreter, not the virtualenv bin/python.
29
+ """ # noqa: D415
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import site
35
+ import sys
36
+
37
+ try:
38
+ abs_file = os.path.abspath(__file__)
39
+ except NameError as exc:
40
+ msg = "You must use import runpy; runpy.run_path(this_file)"
41
+ raise AssertionError(msg) from exc
42
+
43
+ bin_dir = os.path.dirname(abs_file)
44
+ base = bin_dir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator
45
+
46
+ # prepend bin to PATH (this file is inside the bin directory)
47
+ os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
48
+ os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
49
+ os.environ["VIRTUAL_ENV_PROMPT"] = "" or os.path.basename(base) # noqa: SIM222
50
+
51
+ # add the virtual environments libraries to the host python import mechanism
52
+ prev_length = len(sys.path)
53
+ for lib in "../lib/python3.10/site-packages".split(os.pathsep):
54
+ path = os.path.realpath(os.path.join(bin_dir, lib))
55
+ site.addsitedir(path)
56
+ sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
57
+
58
+ sys.real_prefix = sys.prefix
59
+ sys.prefix = base
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2020-202x The virtualenv developers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ """
23
+ Activate virtualenv for current interpreter:
24
+
25
+ import runpy
26
+ runpy.run_path(this_file)
27
+
28
+ This can be used when you must use an existing Python interpreter, not the virtualenv bin/python.
29
+ """ # noqa: D415
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import site
35
+ import sys
36
+
37
+ try:
38
+ abs_file = os.path.abspath(__file__)
39
+ except NameError as exc:
40
+ msg = "You must use import runpy; runpy.run_path(this_file)"
41
+ raise AssertionError(msg) from exc
42
+
43
+ bin_dir = os.path.dirname(abs_file)
44
+ base = bin_dir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator
45
+
46
+ # prepend bin to PATH (this file is inside the bin directory)
47
+ os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
48
+ os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
49
+ os.environ["VIRTUAL_ENV_PROMPT"] = "" or os.path.basename(base) # noqa: SIM222
50
+
51
+ # add the virtual environments libraries to the host python import mechanism
52
+ prev_length = len(sys.path)
53
+ for lib in "../lib/python3.11/site-packages".split(os.pathsep):
54
+ path = os.path.realpath(os.path.join(bin_dir, lib))
55
+ site.addsitedir(path)
56
+ sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
57
+
58
+ sys.real_prefix = sys.prefix
59
+ sys.prefix = base
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2020-202x The virtualenv developers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ """
23
+ Activate virtualenv for current interpreter:
24
+
25
+ import runpy
26
+ runpy.run_path(this_file)
27
+
28
+ This can be used when you must use an existing Python interpreter, not the virtualenv bin/python.
29
+ """ # noqa: D415
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import site
35
+ import sys
36
+
37
+ try:
38
+ abs_file = os.path.abspath(__file__)
39
+ except NameError as exc:
40
+ msg = "You must use import runpy; runpy.run_path(this_file)"
41
+ raise AssertionError(msg) from exc
42
+
43
+ bin_dir = os.path.dirname(abs_file)
44
+ base = bin_dir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator
45
+
46
+ # prepend bin to PATH (this file is inside the bin directory)
47
+ os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
48
+ os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
49
+ os.environ["VIRTUAL_ENV_PROMPT"] = "" or os.path.basename(base) # noqa: SIM222
50
+
51
+ # add the virtual environments libraries to the host python import mechanism
52
+ prev_length = len(sys.path)
53
+ for lib in "../lib/python3.14/site-packages".split(os.pathsep):
54
+ path = os.path.realpath(os.path.join(bin_dir, lib))
55
+ site.addsitedir(path)
56
+ sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
57
+
58
+ sys.real_prefix = sys.prefix
59
+ sys.prefix = base
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2020-202x The virtualenv developers
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ """
23
+ Activate virtualenv for current interpreter:
24
+
25
+ import runpy
26
+ runpy.run_path(this_file)
27
+
28
+ This can be used when you must use an existing Python interpreter, not the virtualenv bin/python.
29
+ """ # noqa: D415
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import site
35
+ import sys
36
+
37
+ try:
38
+ abs_file = os.path.abspath(__file__)
39
+ except NameError as exc:
40
+ msg = "You must use import runpy; runpy.run_path(this_file)"
41
+ raise AssertionError(msg) from exc
42
+
43
+ bin_dir = os.path.dirname(abs_file)
44
+ base = bin_dir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator
45
+
46
+ # prepend bin to PATH (this file is inside the bin directory)
47
+ os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
48
+ os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
49
+ os.environ["VIRTUAL_ENV_PROMPT"] = "" or os.path.basename(base) # noqa: SIM222
50
+
51
+ # add the virtual environments libraries to the host python import mechanism
52
+ prev_length = len(sys.path)
53
+ for lib in "../lib/python3.15/site-packages".split(os.pathsep):
54
+ path = os.path.realpath(os.path.join(bin_dir, lib))
55
+ site.addsitedir(path)
56
+ sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
57
+
58
+ sys.real_prefix = sys.prefix
59
+ sys.prefix = base
deadcode/__init__.py CHANGED
@@ -2,7 +2,7 @@ try:
2
2
  import importlib.metadata
3
3
 
4
4
  __version__ = importlib.metadata.version(__package__ or __name__)
5
- except ImportError:
5
+ except ImportError: # pragma: no cover
6
6
  import importlib_metadata
7
7
 
8
8
  __version__ = importlib_metadata.version(__package__ or __name__)
@@ -1,5 +1,4 @@
1
1
  from logging import getLogger
2
- from typing import List
3
2
  from pathlib import Path
4
3
 
5
4
  from deadcode.data_types import Args
@@ -8,9 +7,9 @@ from deadcode.visitor.ignore import _match
8
7
  logger = getLogger()
9
8
 
10
9
 
11
- def find_python_filenames(args: Args) -> List[str]:
10
+ def find_python_filenames(args: Args) -> list[str]:
12
11
  filenames = []
13
- paths: List[str] = list(args.paths)
12
+ paths: list[str] = list(args.paths)
14
13
  while paths:
15
14
  path = Path(paths.pop())
16
15
 
@@ -32,18 +31,3 @@ def find_python_filenames(args: Args) -> List[str]:
32
31
  sep = '\n - '
33
32
  logger.info(f'Files to be checked for dead code: {sep.join(filenames)}')
34
33
  return filenames
35
-
36
-
37
- # Should show errors, if files do not exist.
38
- # Could mock directories: os.path.isfile os.walk -> replace this one with rglob
39
-
40
- # Should use list dir myself.
41
-
42
- # TODO: Whats the solution for mocking the file system checking?
43
- # Every single provided file is assumed to exist.
44
-
45
-
46
- # # TODO: investigate what can be reused from this module
47
- # Loop over all given paths, abort if any ends with .pyc, add the other given
48
- # files (even those not ending with .py) and collect all .py files under the
49
- # given directories.
@@ -1,4 +1,4 @@
1
- from typing import List, Iterable
1
+ from collections.abc import Iterable
2
2
 
3
3
  from deadcode.data_types import Args, Filename
4
4
  from deadcode.visitor.code_item import CodeItem
@@ -6,7 +6,7 @@ from deadcode.visitor.dead_code_visitor import DeadCodeVisitor
6
6
 
7
7
 
8
8
  def find_unused_names(
9
- filenames: List[Filename],
9
+ filenames: list[Filename],
10
10
  args: Args,
11
11
  ) -> Iterable[CodeItem]:
12
12
  dead_code_visitor = DeadCodeVisitor(filenames, args)
@@ -1,8 +1,9 @@
1
1
  from collections import defaultdict
2
2
  from difflib import diff_bytes, unified_diff
3
- from typing import Iterable
3
+ from collections.abc import Iterable
4
4
  import os
5
5
 
6
+ from deadcode.actions.get_unused_import_parts import get_unused_import_parts
6
7
  from deadcode.actions.merge_overlaping_file_parts import merge_overlaping_file_parts
7
8
  from deadcode.actions.remove_file_parts_from_content import remove_file_parts_from_content
8
9
  from deadcode.data_types import Args
@@ -19,7 +20,7 @@ def fix_or_show_unused_code(unused_items: Iterable[CodeItem], args: Args) -> str
19
20
  # For each filename find file parts, which have to be fixed.
20
21
  # And fix those file parts.
21
22
  #
22
- # TODO: trailing commas, trailing white spaces and empty lines
23
+ # TODO: remove leftover trailing commas, whitespace and empty lines for non-import expressions.
23
24
 
24
25
  filename_to_unused_items = defaultdict(list)
25
26
  for unused_item in unused_items:
@@ -28,13 +29,20 @@ def fix_or_show_unused_code(unused_items: Iterable[CodeItem], args: Args) -> str
28
29
  result = []
29
30
 
30
31
  for filename, file_unused_items in filename_to_unused_items.items():
31
- file_parts = flatten_list([item.code_parts for item in file_unused_items])
32
+ import_items = [item for item in file_unused_items if item.type_ == 'import']
33
+ other_items = [item for item in file_unused_items if item.type_ != 'import']
32
34
 
33
- unused_file_parts = merge_overlaping_file_parts(file_parts)
35
+ with open(filename, 'rb') as f:
36
+ file_content = f.read()
34
37
 
35
38
  with open(filename, 'rb') as f:
36
39
  file_content_lines = f.readlines()
37
40
 
41
+ import_parts = get_unused_import_parts(file_content, import_items) if import_items else []
42
+ file_parts = flatten_list([item.code_parts for item in other_items]) + import_parts
43
+
44
+ unused_file_parts = merge_overlaping_file_parts(file_parts)
45
+
38
46
  updated_file_content_lines = remove_file_parts_from_content(file_content_lines, unused_file_parts)
39
47
  updated_file_content = b''.join(updated_file_content_lines)
40
48
  if updated_file_content.strip():
@@ -58,7 +66,6 @@ def fix_or_show_unused_code(unused_items: Iterable[CodeItem], args: Args) -> str
58
66
 
59
67
  elif args.fix:
60
68
  with open(filename, 'wb') as f:
61
- # TODO: is there a method writelines?
62
69
  f.write(updated_file_content)
63
70
  else:
64
71
  os.remove(filename)
@@ -66,7 +73,3 @@ def fix_or_show_unused_code(unused_items: Iterable[CodeItem], args: Args) -> str
66
73
  if result:
67
74
  return b'\n'.join(result).decode()
68
75
  return ''
69
-
70
- # TODO: update this one: solution is to use read and write operations.
71
- #
72
- # Solution is to open file for reading and then for writing.
@@ -0,0 +1,207 @@
1
+ import ast
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from deadcode.data_types import Part
6
+ from deadcode.utils.flatten_lists import flatten_list
7
+ from deadcode.visitor.code_item import CodeItem
8
+
9
+ ImportAliasKey = tuple[str, int | None, int | None]
10
+
11
+
12
+ def get_import_alias_key(alias: ast.alias) -> ImportAliasKey:
13
+ """Returns the key that identifies an import alias in reported findings."""
14
+ return alias.asname or alias.name.partition('.')[0], alias.lineno, alias.col_offset
15
+
16
+
17
+ def _line_bytes(content_lines: list[bytes], line_number: int) -> bytes:
18
+ if 1 <= line_number <= len(content_lines):
19
+ return content_lines[line_number - 1]
20
+ return b''
21
+
22
+
23
+ def _find_byte(content_lines: list[bytes], line_number: int, start_col: int, end_col: int, needle: bytes) -> int | None:
24
+ position = _line_bytes(content_lines, line_number).find(needle, start_col, end_col)
25
+ if position == -1:
26
+ return None
27
+ return position
28
+
29
+
30
+ def _find_comma_between(
31
+ content_lines: list[bytes], start: tuple[int, int], end: tuple[int, int]
32
+ ) -> tuple[int, int] | None:
33
+ """Returns the position of the first comma between two source positions."""
34
+ start_line, start_col = start
35
+ end_line, end_col = end
36
+
37
+ for line_number in range(start_line, end_line + 1):
38
+ line = _line_bytes(content_lines, line_number)
39
+ if line_number == start_line and line_number == end_line:
40
+ position = _find_byte(content_lines, line_number, start_col, end_col, b',')
41
+ elif line_number == start_line:
42
+ position = _find_byte(content_lines, line_number, start_col, len(line), b',')
43
+ elif line_number == end_line:
44
+ position = _find_byte(content_lines, line_number, 0, end_col, b',')
45
+ else:
46
+ position = _find_byte(content_lines, line_number, 0, len(line), b',')
47
+
48
+ if position is not None:
49
+ return line_number, position
50
+
51
+ return None
52
+
53
+
54
+ def _end_of_line_content(content_lines: list[bytes], line_number: int) -> int:
55
+ """Returns the length of a line without its trailing line terminator."""
56
+ line = _line_bytes(content_lines, line_number)
57
+ for terminator in (b'\r\n', b'\n', b'\r'):
58
+ if line.endswith(terminator):
59
+ return len(line) - len(terminator)
60
+ return len(line)
61
+
62
+
63
+ def _first_non_whitespace_column(content_lines: list[bytes], line_number: int, start_column: int) -> int | None:
64
+ """Returns the first non-whitespace column at or after ``start_column``."""
65
+ line = _line_bytes(content_lines, line_number)
66
+ end_column = _end_of_line_content(content_lines, line_number)
67
+ for column in range(start_column, end_column):
68
+ if line[column : column + 1] not in (b' ', b'\t'):
69
+ return column
70
+ return None
71
+
72
+
73
+ def _last_non_whitespace_column_before(content_lines: list[bytes], line_number: int, end_column: int) -> int | None:
74
+ """Returns the last non-whitespace column strictly before ``end_column``."""
75
+ line = _line_bytes(content_lines, line_number)
76
+ for column in range(end_column - 1, -1, -1):
77
+ if line[column : column + 1] not in (b' ', b'\t'):
78
+ return column
79
+ return None
80
+
81
+
82
+ def _is_only_whitespace_or_comment(remainder: bytes) -> bool:
83
+ """Returns whether the remainder is whitespace or a trailing comment."""
84
+ stripped_remainder = remainder.strip()
85
+ return not stripped_remainder or stripped_remainder.startswith(b'#')
86
+
87
+
88
+ def _end_of_whitespace_after_comma(content_lines: list[bytes], comma: tuple[int, int]) -> int:
89
+ line = _line_bytes(content_lines, comma[0])
90
+ end_col = comma[1] + 1
91
+ while end_col < len(line) and line[end_col : end_col + 1] in (b' ', b'\t'):
92
+ end_col += 1
93
+ return end_col
94
+
95
+
96
+ def get_unused_import_parts(content: bytes, unused_import_items: Iterable[CodeItem]) -> list[Part]:
97
+ """Returns code parts to remove for unused import findings.
98
+
99
+ When every non-wildcard alias of an import statement is unused, the whole
100
+ statement is returned as a single part. Otherwise each unused alias is
101
+ returned together with one adjacent comma, so the remaining aliases keep
102
+ valid comma placement.
103
+ """
104
+ unused_import_items = list(unused_import_items)
105
+ fallback_parts = flatten_list([item.code_parts for item in unused_import_items])
106
+ removable_keys = {(item.name, item.name_line, item.name_column) for item in unused_import_items}
107
+
108
+ try:
109
+ tree = ast.parse(content)
110
+ except SyntaxError:
111
+ return fallback_parts
112
+
113
+ content_lines = content.splitlines(keepends=True)
114
+ import_parts: list[Part] = []
115
+ matched_keys: set[ImportAliasKey] = set()
116
+
117
+ for node in ast.walk(tree):
118
+ if not isinstance(node, (ast.Import, ast.ImportFrom)):
119
+ continue
120
+
121
+ # A wildcard alias is never removable.
122
+ aliases = [alias for alias in node.names if alias.name != '*']
123
+ if not aliases:
124
+ continue
125
+
126
+ alias_keys = [get_import_alias_key(alias) for alias in aliases]
127
+
128
+ if all(key in removable_keys for key in alias_keys):
129
+ if node.end_lineno is None or node.end_col_offset is None:
130
+ continue
131
+
132
+ start_column = node.col_offset
133
+ end_column = node.end_col_offset
134
+ following_column = _first_non_whitespace_column(content_lines, node.end_lineno, end_column)
135
+ if (
136
+ following_column is not None
137
+ and _line_bytes(content_lines, node.end_lineno)[following_column : following_column + 1] == b';'
138
+ ):
139
+ end_column = following_column + 1
140
+ after_separator_column = _first_non_whitespace_column(content_lines, node.end_lineno, end_column)
141
+ if after_separator_column is not None:
142
+ end_column = after_separator_column
143
+ else:
144
+ preceding_column = _last_non_whitespace_column_before(content_lines, node.lineno, node.col_offset)
145
+ if (
146
+ preceding_column is not None
147
+ and _line_bytes(content_lines, node.lineno)[preceding_column : preceding_column + 1] == b';'
148
+ ):
149
+ start_column = preceding_column
150
+
151
+ line_content_end = _end_of_line_content(content_lines, node.end_lineno)
152
+ remainder = _line_bytes(content_lines, node.end_lineno)[end_column:line_content_end]
153
+ if _is_only_whitespace_or_comment(remainder):
154
+ end_column = line_content_end
155
+
156
+ import_parts.append(Part(node.lineno, node.end_lineno, start_column, end_column))
157
+ matched_keys.update(alias_keys)
158
+ continue
159
+
160
+ for index, alias in enumerate(aliases):
161
+ key = alias_keys[index]
162
+ if key not in removable_keys or alias.end_lineno is None or alias.end_col_offset is None:
163
+ continue
164
+
165
+ if index + 1 < len(aliases):
166
+ next_alias = aliases[index + 1]
167
+ comma = _find_comma_between(
168
+ content_lines,
169
+ (alias.end_lineno, alias.end_col_offset),
170
+ (next_alias.lineno, next_alias.col_offset),
171
+ )
172
+ if comma is None:
173
+ matched_keys.add(key)
174
+ continue
175
+ end_col = _end_of_whitespace_after_comma(content_lines, comma)
176
+ import_parts.append(Part(alias.lineno, comma[0], alias.col_offset, end_col))
177
+ else:
178
+ previous_alias = aliases[index - 1]
179
+ if previous_alias.end_lineno is None or previous_alias.end_col_offset is None:
180
+ import_parts.append(Part(alias.lineno, alias.end_lineno, alias.col_offset, alias.end_col_offset))
181
+ else:
182
+ comma = _find_comma_between(
183
+ content_lines,
184
+ (previous_alias.end_lineno, previous_alias.end_col_offset),
185
+ (alias.lineno, alias.col_offset),
186
+ )
187
+ if comma is None:
188
+ matched_keys.add(key)
189
+ continue
190
+ if previous_alias.end_lineno != alias.lineno:
191
+ import_parts.append(
192
+ Part(comma[0], comma[0], comma[1], _end_of_line_content(content_lines, comma[0]))
193
+ )
194
+ import_parts.append(Part(alias.lineno, alias.end_lineno, 0, alias.end_col_offset))
195
+ else:
196
+ import_parts.append(Part(comma[0], alias.end_lineno, comma[1], alias.end_col_offset))
197
+
198
+ matched_keys.add(key)
199
+
200
+ remaining_fallback_parts = [
201
+ part
202
+ for item in unused_import_items
203
+ if (item.name, item.name_line, item.name_column) not in matched_keys
204
+ for part in item.code_parts
205
+ ]
206
+
207
+ return list(dict.fromkeys(import_parts + remaining_fallback_parts))
@@ -1,15 +1,20 @@
1
- from typing import Iterable, Optional
1
+ from collections.abc import Iterable
2
2
 
3
3
  from deadcode.data_types import Args
4
4
  from deadcode.visitor.code_item import CodeItem
5
5
  from deadcode.visitor.ignore import _match
6
6
 
7
7
 
8
- def get_unused_names_error_message(unused_names: Iterable[CodeItem], args: Args) -> Optional[str]:
8
+ def get_unused_names_error_message(unused_names: Iterable[CodeItem], args: Args) -> str | None:
9
9
  unused_names = list(unused_names)
10
+ had_unused_names = bool(unused_names)
11
+
12
+ if args.only:
13
+ unused_names = [item for item in unused_names if _match(item.filename, args.only)]
10
14
 
11
15
  if not unused_names:
12
- return None
16
+ # Filtering away every finding keeps the historical empty message instead of reporting a clean run.
17
+ return '' if had_unused_names and not args.count and not args.quiet else None
13
18
 
14
19
  if args.quiet:
15
20
  return ''
@@ -19,14 +24,13 @@ def get_unused_names_error_message(unused_names: Iterable[CodeItem], args: Args)
19
24
 
20
25
  messages = []
21
26
  for item in unused_names:
22
- if not args.only or _match(item.filename, args.only):
23
- message = f'{item.filename_with_position} \033[91m{item.error_code}\033[0m '
24
- message += item.message or (
25
- f"{item.type_.replace('_', ' ').capitalize()} " f"`\033[1m{item.name}\033[0m` " f"is never used"
26
- )
27
- if args.no_color:
28
- message = message.replace('\033[91m', '').replace('\033[1m', '').replace('\033[0m', '')
29
- messages.append(message)
27
+ message = f'{item.filename_with_position} \033[91m{item.error_code}\033[0m '
28
+ message += item.message or (
29
+ f"{item.type_.replace('_', ' ').capitalize()} " f"`\033[1m{item.name}\033[0m` " f"is never used"
30
+ )
31
+ if args.no_color:
32
+ message = message.replace('\033[91m', '').replace('\033[1m', '').replace('\033[0m', '')
33
+ messages.append(message)
30
34
 
31
35
  if args.fix:
32
36
  message = f"\nRemoved \033[1m{len(messages)}\033[0m unused code item{'s' if len(messages) > 1 else ''}!"
@@ -1,4 +1,3 @@
1
- from typing import List, Optional, Tuple
2
1
  from deadcode.data_types import Part
3
2
 
4
3
 
@@ -13,7 +12,7 @@ def does_include(bigger_part: Part, smaller_part: Part) -> bool:
13
12
  return bool(starts_later and ends_faster)
14
13
 
15
14
 
16
- def sort_parts(bigger_part: Part, smaller_part: Part) -> Tuple[Part, Part]:
15
+ def sort_parts(bigger_part: Part, smaller_part: Part) -> tuple[Part, Part]:
17
16
  """Returns code part which begins first following by another code part."""
18
17
  # TODO: Column should go first (tuple comparison would be possible)
19
18
  line_start_b, line_end_b, col_start_b, col_end_b = bigger_part
@@ -35,7 +34,7 @@ def does_overlap(bigger_part: Part, smaller_part: Part) -> bool:
35
34
  return bool((line_end_b > line_start_s) or ((line_end_b == line_start_s) and (col_end_b > col_start_s)))
36
35
 
37
36
 
38
- def merge_parts(p1: Part, p2: Part) -> Optional[Part]:
37
+ def merge_parts(p1: Part, p2: Part) -> Part | None:
39
38
  p1, p2 = sort_parts(p1, p2)
40
39
 
41
40
  line_start1, line_end1, col_start1, col_end1 = p1
@@ -58,10 +57,10 @@ def merge_parts(p1: Part, p2: Part) -> Optional[Part]:
58
57
  return None
59
58
 
60
59
 
61
- def merge_overlaping_file_parts(overlaping_file_parts: List[Part]) -> List[Part]:
60
+ def merge_overlaping_file_parts(overlaping_file_parts: list[Part]) -> list[Part]:
62
61
  # Make algorithm O(n^2) by checking every single part if overlaps
63
62
 
64
- non_overlaping_file_parts: List[Part] = []
63
+ non_overlaping_file_parts: list[Part] = []
65
64
  for p1 in sorted(overlaping_file_parts):
66
65
  merged_part = None
67
66
  merged_with_index = None
@@ -1,6 +1,7 @@
1
1
  import ast
2
2
 
3
3
  from deadcode.data_types import Args, FileContent, AbstractSyntaxTree
4
+ from deadcode.utils.print_safely import print_safely
4
5
 
5
6
 
6
7
  def parse_abstract_syntax_tree(file_content: FileContent, args: Args, filename: str) -> AbstractSyntaxTree:
@@ -9,5 +10,5 @@ def parse_abstract_syntax_tree(file_content: FileContent, args: Args, filename:
9
10
  return ast.parse(file_content, filename=filename, type_comments=True)
10
11
  except: # noqa: E722
11
12
  if not args.count and not args.quiet:
12
- print(f'Error: Failed to parse {filename} file, ignoring it.')
13
+ print_safely(f'Error: Failed to parse {filename} file, ignoring it.')
13
14
  return ast.Module()