lapsed 2.4.1__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.
- deadcode/__init__.py +8 -0
- deadcode/actions/__init__.py +0 -0
- deadcode/actions/find_python_filenames.py +57 -0
- deadcode/actions/find_unused_names.py +15 -0
- deadcode/actions/fix_or_show_unused_code.py +72 -0
- deadcode/actions/get_unused_names_error_message.py +37 -0
- deadcode/actions/merge_overlaping_file_parts.py +83 -0
- deadcode/actions/parse_abstract_syntax_tree.py +13 -0
- deadcode/actions/parse_arguments.py +250 -0
- deadcode/actions/parse_tach_config.py +207 -0
- deadcode/actions/remove_file_parts_from_content.py +166 -0
- deadcode/cli.py +46 -0
- deadcode/constants.py +49 -0
- deadcode/data_types.py +46 -0
- deadcode/utils/__init__.py +0 -0
- deadcode/utils/add_colors_to_diff.py +10 -0
- deadcode/utils/base_test_case.py +88 -0
- deadcode/utils/fix_indent.py +70 -0
- deadcode/utils/flatten_lists.py +19 -0
- deadcode/utils/nested_scopes.py +83 -0
- deadcode/utils/print_ast.py +5 -0
- deadcode/visitor/__init__.py +0 -0
- deadcode/visitor/code_item.py +140 -0
- deadcode/visitor/dead_code_visitor.py +563 -0
- deadcode/visitor/ignore.py +135 -0
- deadcode/visitor/lines.py +82 -0
- deadcode/visitor/noqa.py +68 -0
- deadcode/visitor/utils.py +87 -0
- lapsed-2.4.1.dist-info/METADATA +245 -0
- lapsed-2.4.1.dist-info/RECORD +33 -0
- lapsed-2.4.1.dist-info/WHEEL +4 -0
- lapsed-2.4.1.dist-info/entry_points.txt +2 -0
- lapsed-2.4.1.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
logger = getLogger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TachInterface:
|
|
18
|
+
expose: List[str]
|
|
19
|
+
from_patterns: Optional[List[str]] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TachModule:
|
|
24
|
+
path_patterns: List[str]
|
|
25
|
+
unchecked: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TachConfig:
|
|
30
|
+
source_roots: List[Path]
|
|
31
|
+
modules: List[TachModule] = field(default_factory=list)
|
|
32
|
+
interfaces: List[TachInterface] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TachIndex:
|
|
36
|
+
"""Answers dead-code-relevant questions derived from one or more tach.toml files."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, configs: List[TachConfig]) -> None:
|
|
39
|
+
self._configs = configs
|
|
40
|
+
|
|
41
|
+
def is_unchecked(self, file: Path) -> bool:
|
|
42
|
+
for config in self._configs:
|
|
43
|
+
module_path = _dotted_module_path(file, config.source_roots)
|
|
44
|
+
if module_path is None:
|
|
45
|
+
continue
|
|
46
|
+
for module in config.modules:
|
|
47
|
+
if module.unchecked and _match_any_dotted_glob(module.path_patterns, module_path):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def is_exposed(self, file: Path, name: str) -> bool:
|
|
52
|
+
for config in self._configs:
|
|
53
|
+
module_path = _dotted_module_path(file, config.source_roots)
|
|
54
|
+
if module_path is None:
|
|
55
|
+
continue
|
|
56
|
+
for interface in config.interfaces:
|
|
57
|
+
adopts_interface = interface.from_patterns is None or _match_any_regex(
|
|
58
|
+
interface.from_patterns, module_path
|
|
59
|
+
)
|
|
60
|
+
if adopts_interface and _match_any_regex(interface.expose, name):
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_tach_index(tach_config_paths: Iterable[str]) -> TachIndex:
|
|
66
|
+
configs = []
|
|
67
|
+
for raw_path in tach_config_paths:
|
|
68
|
+
path = Path(raw_path).resolve()
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
logger.error(f'Error: tach config {path} could not be found.')
|
|
71
|
+
continue
|
|
72
|
+
configs.append(_parse_tach_toml(path))
|
|
73
|
+
return TachIndex(configs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_tach_toml(path: Path) -> TachConfig:
|
|
77
|
+
with open(path, 'rb') as f:
|
|
78
|
+
data = tomllib.load(f)
|
|
79
|
+
|
|
80
|
+
project_root = path.parent
|
|
81
|
+
raw_source_roots = data.get('source_roots') or ['.']
|
|
82
|
+
source_roots = [(project_root / root).resolve() for root in raw_source_roots]
|
|
83
|
+
|
|
84
|
+
config = TachConfig(source_roots=source_roots)
|
|
85
|
+
config.modules.extend(_extract_modules(data))
|
|
86
|
+
config.interfaces.extend(_extract_interfaces(data))
|
|
87
|
+
|
|
88
|
+
for source_root in source_roots:
|
|
89
|
+
if not source_root.is_dir():
|
|
90
|
+
continue
|
|
91
|
+
for domain_file in sorted(source_root.rglob('tach.domain.toml')):
|
|
92
|
+
_merge_domain_toml(config, domain_file, source_root)
|
|
93
|
+
|
|
94
|
+
return config
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extract_modules(data: Dict[str, Any]) -> List[TachModule]:
|
|
98
|
+
modules = []
|
|
99
|
+
for raw in data.get('modules', []):
|
|
100
|
+
if 'paths' in raw:
|
|
101
|
+
patterns = list(raw['paths'])
|
|
102
|
+
elif 'path' in raw:
|
|
103
|
+
patterns = [raw['path']]
|
|
104
|
+
else:
|
|
105
|
+
continue
|
|
106
|
+
modules.append(TachModule(path_patterns=patterns, unchecked=bool(raw.get('unchecked', False))))
|
|
107
|
+
return modules
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_interfaces(data: Dict[str, Any]) -> List[TachInterface]:
|
|
111
|
+
interfaces = []
|
|
112
|
+
for raw in data.get('interfaces', []):
|
|
113
|
+
expose = list(raw.get('expose', []))
|
|
114
|
+
if not expose:
|
|
115
|
+
continue
|
|
116
|
+
from_patterns = list(raw['from']) if 'from' in raw else None
|
|
117
|
+
interfaces.append(TachInterface(expose=expose, from_patterns=from_patterns))
|
|
118
|
+
return interfaces
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _merge_domain_toml(config: TachConfig, domain_file: Path, source_root: Path) -> None:
|
|
122
|
+
with open(domain_file, 'rb') as f:
|
|
123
|
+
data = tomllib.load(f)
|
|
124
|
+
|
|
125
|
+
domain_root_dotted = _dotted_dir_path(domain_file.parent, source_root)
|
|
126
|
+
|
|
127
|
+
def resolve(entry: str) -> str:
|
|
128
|
+
if entry.startswith('//'):
|
|
129
|
+
return entry[2:]
|
|
130
|
+
if entry == '':
|
|
131
|
+
return domain_root_dotted
|
|
132
|
+
if domain_root_dotted:
|
|
133
|
+
return f'{domain_root_dotted}.{entry}'
|
|
134
|
+
return entry
|
|
135
|
+
|
|
136
|
+
modules = _extract_modules(data)
|
|
137
|
+
for module in modules:
|
|
138
|
+
module.path_patterns = [resolve(pattern) for pattern in module.path_patterns]
|
|
139
|
+
config.modules.extend(modules)
|
|
140
|
+
|
|
141
|
+
interfaces = _extract_interfaces(data)
|
|
142
|
+
for interface in interfaces:
|
|
143
|
+
if interface.from_patterns is not None:
|
|
144
|
+
interface.from_patterns = [resolve(pattern) for pattern in interface.from_patterns]
|
|
145
|
+
config.interfaces.extend(interfaces)
|
|
146
|
+
|
|
147
|
+
root_table = data.get('root')
|
|
148
|
+
if isinstance(root_table, dict) and root_table.get('unchecked'):
|
|
149
|
+
config.modules.append(TachModule(path_patterns=[domain_root_dotted], unchecked=True))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _dotted_dir_path(directory: Path, source_root: Path) -> str:
|
|
153
|
+
directory = directory.resolve()
|
|
154
|
+
source_root = source_root.resolve()
|
|
155
|
+
if directory == source_root:
|
|
156
|
+
return ''
|
|
157
|
+
return '.'.join(directory.relative_to(source_root).parts)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dotted_module_path(file: Path, source_roots: List[Path]) -> Optional[str]:
|
|
161
|
+
file = file.resolve()
|
|
162
|
+
|
|
163
|
+
best_root: Optional[Path] = None
|
|
164
|
+
for source_root in source_roots:
|
|
165
|
+
source_root = source_root.resolve()
|
|
166
|
+
if source_root in file.parents and (best_root is None or len(source_root.parts) > len(best_root.parts)):
|
|
167
|
+
best_root = source_root
|
|
168
|
+
|
|
169
|
+
if best_root is None:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
parts = list(file.relative_to(best_root).parts)
|
|
173
|
+
if parts and parts[-1] == '__init__.py':
|
|
174
|
+
parts = parts[:-1]
|
|
175
|
+
elif parts and parts[-1].endswith('.py'):
|
|
176
|
+
parts[-1] = parts[-1][: -len('.py')]
|
|
177
|
+
else:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
return '.'.join(parts) if parts else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _dotted_glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
184
|
+
regex_segments = [
|
|
185
|
+
'.*' if segment == '**' else re.escape(segment).replace(r'\*', '[^.]*') for segment in pattern.split('.')
|
|
186
|
+
]
|
|
187
|
+
return re.compile(r'\.'.join(regex_segments))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _match_any_dotted_glob(patterns: Iterable[str], dotted_path: str) -> bool:
|
|
191
|
+
return any(_dotted_glob_to_regex(pattern).fullmatch(dotted_path) for pattern in patterns)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _try_compile_regex(pattern: str) -> Optional[re.Pattern[str]]:
|
|
195
|
+
try:
|
|
196
|
+
return re.compile(pattern)
|
|
197
|
+
except re.error:
|
|
198
|
+
logger.exception(f'Error: invalid tach interface regex pattern {pattern!r}.')
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _match_any_regex(patterns: Iterable[str], value: str) -> bool:
|
|
203
|
+
return any(
|
|
204
|
+
compiled.fullmatch(value)
|
|
205
|
+
for pattern in patterns
|
|
206
|
+
if (compiled := _try_compile_regex(pattern)) is not None
|
|
207
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import List, Optional, TypeVar
|
|
3
|
+
|
|
4
|
+
from deadcode.data_types import Part
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def list_get(list_: List[T], index: int) -> Optional[T]:
|
|
10
|
+
if len(list_) > index:
|
|
11
|
+
return list_[index]
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ends_with_semicolon(line: bytes) -> bool:
|
|
16
|
+
return line.strip().endswith(b':')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def indentation_is_not_childs(previous_line: bytes, current_line: bytes) -> bool:
|
|
20
|
+
return len(get_indentation(previous_line)) >= len(get_indentation(current_line))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_indentation(line: bytes) -> bytes:
|
|
24
|
+
return bytes(re.findall(b'^\\s*', line)[0])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def remove_as_from_end(line: bytes) -> bytes:
|
|
28
|
+
if not (line_rstrip := line.rstrip()).endswith(b'as'):
|
|
29
|
+
return line
|
|
30
|
+
|
|
31
|
+
# Is there white spaces before as
|
|
32
|
+
if line_rstrip[:-2] != (line_with_removed_as := line_rstrip[:-2].rstrip()):
|
|
33
|
+
return line_with_removed_as
|
|
34
|
+
return line
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def remove_comma_from_begining(line: bytes) -> bytes:
|
|
38
|
+
if not line.lstrip().startswith(b','):
|
|
39
|
+
return line
|
|
40
|
+
|
|
41
|
+
return line.lstrip()[1:].lstrip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove_file_parts_from_content(content_lines: List[bytes], unused_file_parts: List[Part]) -> List[bytes]:
|
|
45
|
+
""" """
|
|
46
|
+
# How should move through the lines of content?
|
|
47
|
+
updated_content_lines = []
|
|
48
|
+
|
|
49
|
+
# TODO: iterate by line and crop that line if needed.
|
|
50
|
+
unused_part_index = 0
|
|
51
|
+
|
|
52
|
+
previous_non_removed_line = b''
|
|
53
|
+
was_block_removed = False
|
|
54
|
+
next_line_after_removed_block = None
|
|
55
|
+
indentation_of_first_removed_line = b''
|
|
56
|
+
empty_lines_in_a_row_list: List[bytes] = []
|
|
57
|
+
empty_lines_before_removed_block_list: List[bytes] = []
|
|
58
|
+
|
|
59
|
+
for current_lineno, line in enumerate(content_lines, start=1):
|
|
60
|
+
from_line, to_line, from_col, to_col = 0, 0, 0, 0
|
|
61
|
+
if unused_part := list_get(unused_file_parts, unused_part_index):
|
|
62
|
+
from_line, to_line, from_col, to_col = unused_part
|
|
63
|
+
|
|
64
|
+
# Skip lines, which have to be ignored.
|
|
65
|
+
if current_lineno > from_line and current_lineno < to_line:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Is it first line, which have to be ignored?
|
|
69
|
+
elif current_lineno == from_line:
|
|
70
|
+
indentation_of_first_removed_line = get_indentation(line)
|
|
71
|
+
|
|
72
|
+
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.
|
|
75
|
+
if (line[:from_col] + line[to_col:]).strip().startswith(b'='):
|
|
76
|
+
line = line[:from_col]
|
|
77
|
+
else:
|
|
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:])
|
|
81
|
+
|
|
82
|
+
unused_part_index += 1
|
|
83
|
+
|
|
84
|
+
if not line.strip():
|
|
85
|
+
empty_lines_before_removed_block_list, empty_lines_in_a_row_list = (
|
|
86
|
+
empty_lines_in_a_row_list,
|
|
87
|
+
empty_lines_before_removed_block_list,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
was_block_removed = True
|
|
91
|
+
else:
|
|
92
|
+
line = line[:from_col]
|
|
93
|
+
|
|
94
|
+
if line.strip() and not line.startswith(b'#'):
|
|
95
|
+
previous_non_removed_line = line
|
|
96
|
+
updated_content_lines.append(line)
|
|
97
|
+
|
|
98
|
+
# Is it last line, from the block, which have to be ignored?
|
|
99
|
+
elif current_lineno == to_line:
|
|
100
|
+
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(b','):
|
|
105
|
+
line = line.lstrip()[1:].lstrip()
|
|
106
|
+
|
|
107
|
+
unused_part_index += 1
|
|
108
|
+
# TODO: Add tests for case, when comments are added at the start or end of the removed block
|
|
109
|
+
if line.strip() and not line.startswith(b'#'):
|
|
110
|
+
updated_content_lines.append(line)
|
|
111
|
+
|
|
112
|
+
empty_lines_before_removed_block_list, empty_lines_in_a_row_list = (
|
|
113
|
+
empty_lines_in_a_row_list,
|
|
114
|
+
empty_lines_before_removed_block_list,
|
|
115
|
+
)
|
|
116
|
+
was_block_removed = True
|
|
117
|
+
|
|
118
|
+
# Do not remove the line
|
|
119
|
+
else:
|
|
120
|
+
if not line.strip():
|
|
121
|
+
empty_lines_in_a_row_list.append(line)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# if was_block_removed: # These lines will be added when first non-empty line is found
|
|
125
|
+
# continue
|
|
126
|
+
elif not was_block_removed:
|
|
127
|
+
updated_content_lines.extend(empty_lines_in_a_row_list)
|
|
128
|
+
empty_lines_in_a_row_list.clear()
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# Add pass if needed - if its a file end we should also check if pass have to be added.
|
|
132
|
+
next_line_after_removed_block = line
|
|
133
|
+
|
|
134
|
+
if ends_with_semicolon(previous_non_removed_line):
|
|
135
|
+
if indentation_is_not_childs(
|
|
136
|
+
previous_line=previous_non_removed_line, current_line=next_line_after_removed_block
|
|
137
|
+
):
|
|
138
|
+
updated_content_lines.append(indentation_of_first_removed_line + b'pass\n')
|
|
139
|
+
|
|
140
|
+
# Add empty lines
|
|
141
|
+
if indentation_is_not_childs(
|
|
142
|
+
previous_line=previous_non_removed_line, current_line=next_line_after_removed_block
|
|
143
|
+
):
|
|
144
|
+
# Add lines after
|
|
145
|
+
updated_content_lines.extend(empty_lines_in_a_row_list)
|
|
146
|
+
empty_lines_in_a_row_list.clear()
|
|
147
|
+
empty_lines_before_removed_block_list.clear()
|
|
148
|
+
else:
|
|
149
|
+
updated_content_lines.extend(empty_lines_before_removed_block_list)
|
|
150
|
+
empty_lines_before_removed_block_list.clear()
|
|
151
|
+
empty_lines_in_a_row_list.clear()
|
|
152
|
+
|
|
153
|
+
else:
|
|
154
|
+
updated_content_lines.extend(empty_lines_before_removed_block_list)
|
|
155
|
+
empty_lines_before_removed_block_list.clear()
|
|
156
|
+
empty_lines_in_a_row_list.clear()
|
|
157
|
+
|
|
158
|
+
was_block_removed = False
|
|
159
|
+
previous_non_removed_line = line
|
|
160
|
+
updated_content_lines.append(line)
|
|
161
|
+
|
|
162
|
+
if was_block_removed:
|
|
163
|
+
if ends_with_semicolon(previous_non_removed_line):
|
|
164
|
+
updated_content_lines.append(indentation_of_first_removed_line + b'pass\n')
|
|
165
|
+
|
|
166
|
+
return updated_content_lines
|
deadcode/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from deadcode import __version__
|
|
5
|
+
from deadcode.actions.find_python_filenames import find_python_filenames
|
|
6
|
+
from deadcode.actions.find_unused_names import find_unused_names
|
|
7
|
+
from deadcode.actions.fix_or_show_unused_code import fix_or_show_unused_code
|
|
8
|
+
from deadcode.actions.parse_arguments import parse_arguments
|
|
9
|
+
from deadcode.actions.get_unused_names_error_message import (
|
|
10
|
+
get_unused_names_error_message,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(
|
|
15
|
+
command_line_args: Optional[List[str]] = None,
|
|
16
|
+
) -> Optional[str]:
|
|
17
|
+
|
|
18
|
+
if command_line_args and '--version' in command_line_args or '--version' in sys.argv:
|
|
19
|
+
return __version__
|
|
20
|
+
|
|
21
|
+
args = parse_arguments(command_line_args)
|
|
22
|
+
|
|
23
|
+
filenames = find_python_filenames(args=args)
|
|
24
|
+
|
|
25
|
+
# TODO: rename unused_names to unused_code_items
|
|
26
|
+
unused_names = find_unused_names(filenames=filenames, args=args)
|
|
27
|
+
|
|
28
|
+
file_diff = None
|
|
29
|
+
if (args.fix or args.dry) and unused_names:
|
|
30
|
+
file_diff = fix_or_show_unused_code(unused_names, args=args)
|
|
31
|
+
|
|
32
|
+
if (error_message := get_unused_names_error_message(unused_names, args=args)) is not None:
|
|
33
|
+
return error_message + ('\n\n' + file_diff if file_diff else '')
|
|
34
|
+
|
|
35
|
+
if not args.count and not args.quiet:
|
|
36
|
+
print('\033[1mWell done!\033[0m ✨ 🚀 ✨')
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_main() -> None:
|
|
41
|
+
if result := main():
|
|
42
|
+
print(result)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == '__main__':
|
|
46
|
+
print_main()
|
deadcode/constants.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Dict, Literal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
UnusedCodeType = Literal[
|
|
5
|
+
'attribute',
|
|
6
|
+
'class',
|
|
7
|
+
'function',
|
|
8
|
+
'import',
|
|
9
|
+
'method',
|
|
10
|
+
'property',
|
|
11
|
+
'variable',
|
|
12
|
+
'unreachable_code',
|
|
13
|
+
'name',
|
|
14
|
+
'unused_file',
|
|
15
|
+
'commented_out_code',
|
|
16
|
+
'ignore_expression',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
UnusedCodeErrorCode = Literal[
|
|
21
|
+
'DC01',
|
|
22
|
+
'DC02',
|
|
23
|
+
'DC03',
|
|
24
|
+
'DC04',
|
|
25
|
+
'DC05',
|
|
26
|
+
'DC06',
|
|
27
|
+
'DC07',
|
|
28
|
+
'DC08',
|
|
29
|
+
'DC09',
|
|
30
|
+
'DC11',
|
|
31
|
+
'DC12',
|
|
32
|
+
'DC13',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ERROR_TYPE_TO_ERROR_CODE: Dict[UnusedCodeType, UnusedCodeErrorCode] = {
|
|
37
|
+
'variable': 'DC01',
|
|
38
|
+
'function': 'DC02',
|
|
39
|
+
'class': 'DC03',
|
|
40
|
+
'method': 'DC04',
|
|
41
|
+
'attribute': 'DC05',
|
|
42
|
+
'name': 'DC06',
|
|
43
|
+
'import': 'DC07',
|
|
44
|
+
'property': 'DC08',
|
|
45
|
+
'unreachable_code': 'DC09',
|
|
46
|
+
'unused_file': 'DC11',
|
|
47
|
+
'commented_out_code': 'DC12',
|
|
48
|
+
'ignore_expression': 'DC13',
|
|
49
|
+
}
|
deadcode/data_types.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable, NamedTuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
AbstractSyntaxTree = ast.Module # Should be module instead of ast
|
|
8
|
+
FileContent = bytes
|
|
9
|
+
Filename = str # Contains full path to existing file
|
|
10
|
+
Pathname = str # Can contain wildewards
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Args:
|
|
15
|
+
fix: bool = False
|
|
16
|
+
verbose: bool = False
|
|
17
|
+
version: bool = False
|
|
18
|
+
dry: bool = False
|
|
19
|
+
only: Iterable[Pathname] = ()
|
|
20
|
+
paths: Iterable[Pathname] = ()
|
|
21
|
+
exclude: Iterable[Pathname] = ()
|
|
22
|
+
ignore_definitions: Iterable[Pathname] = ()
|
|
23
|
+
ignore_definitions_if_decorated_with: Iterable[Pathname] = ()
|
|
24
|
+
ignore_definitions_if_inherits_from: Iterable[Pathname] = ()
|
|
25
|
+
ignore_bodies_of: Iterable[Pathname] = ()
|
|
26
|
+
ignore_bodies_if_decorated_with: Iterable[Pathname] = ()
|
|
27
|
+
ignore_bodies_if_inherits_from: Iterable[Pathname] = ()
|
|
28
|
+
ignore_if_decorated_with: Iterable[Pathname] = ()
|
|
29
|
+
ignore_if_inherits_from: Iterable[Pathname] = ()
|
|
30
|
+
ignore_names: Iterable[Pathname] = ()
|
|
31
|
+
ignore_names_if_decorated_with: Iterable[Pathname] = ()
|
|
32
|
+
ignore_names_if_inherits_from: Iterable[Pathname] = ()
|
|
33
|
+
ignore_names_in_files: Iterable[Pathname] = ()
|
|
34
|
+
tach_config: Iterable[Pathname] = ()
|
|
35
|
+
no_color: bool = False
|
|
36
|
+
quiet: bool = False
|
|
37
|
+
count: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Part(NamedTuple):
|
|
41
|
+
"""Code file part"""
|
|
42
|
+
|
|
43
|
+
line_start: int
|
|
44
|
+
line_end: int
|
|
45
|
+
col_start: int
|
|
46
|
+
col_end: int
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
def add_colors_to_diff(diff: bytes) -> bytes:
|
|
2
|
+
colorful_lines = []
|
|
3
|
+
for line in diff.split(b'\n'):
|
|
4
|
+
colorful_line = line
|
|
5
|
+
if line.startswith(b'-'):
|
|
6
|
+
colorful_line = b'\033[31m' + colorful_line + b'\033[0m'
|
|
7
|
+
if line.startswith(b'+'):
|
|
8
|
+
colorful_line = b'\033[32m' + colorful_line + b'\033[0m'
|
|
9
|
+
colorful_lines.append(colorful_line)
|
|
10
|
+
return b'\n'.join(colorful_lines)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from deadcode.utils.fix_indent import fix_indent
|
|
7
|
+
from deadcode.data_types import Args
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseTestCase(TestCase):
|
|
11
|
+
files: Dict[str, bytes] = {}
|
|
12
|
+
|
|
13
|
+
maxDiff = None
|
|
14
|
+
|
|
15
|
+
def patch(self, path: str) -> MagicMock:
|
|
16
|
+
patcher = patch(path)
|
|
17
|
+
self.addCleanup(patcher.stop)
|
|
18
|
+
return patcher.start()
|
|
19
|
+
|
|
20
|
+
def _get_filenames(self, *args: Any, **kwargs: Any) -> List[str]:
|
|
21
|
+
return list(self.files.keys())
|
|
22
|
+
|
|
23
|
+
def _read_file_side_effect(self, filename: Union[str, Path], *args: Any, **kwargs: Any) -> MagicMock:
|
|
24
|
+
mock = MagicMock()
|
|
25
|
+
mock.filename = str(filename)
|
|
26
|
+
|
|
27
|
+
def cache_file_content(file_content: bytes) -> int:
|
|
28
|
+
self.updated_files[mock.filename] = file_content
|
|
29
|
+
return len(file_content)
|
|
30
|
+
|
|
31
|
+
if file_content := fix_indent(self.files[mock.filename]):
|
|
32
|
+
mock.__enter__().read.return_value = file_content
|
|
33
|
+
mock.__enter__().readlines.return_value = [line + b'\n' for line in file_content.split(b'\n')]
|
|
34
|
+
mock.__enter__().write.side_effect = cache_file_content
|
|
35
|
+
return mock
|
|
36
|
+
|
|
37
|
+
def setUp(self) -> None:
|
|
38
|
+
self.updated_files: Dict[str, bytes] = {}
|
|
39
|
+
|
|
40
|
+
self.find_python_filenames_mock = self.patch('deadcode.cli.find_python_filenames')
|
|
41
|
+
self.find_python_filenames_mock.side_effect = self._get_filenames
|
|
42
|
+
|
|
43
|
+
self.read_file_mock = self.patch('deadcode.visitor.dead_code_visitor.open')
|
|
44
|
+
self.read_file_mock.side_effect = self._read_file_side_effect
|
|
45
|
+
|
|
46
|
+
self.fix_file_mock = self.patch('deadcode.actions.fix_or_show_unused_code.open')
|
|
47
|
+
self.fix_file_mock.side_effect = self._read_file_side_effect
|
|
48
|
+
|
|
49
|
+
self.os_remove = self.patch('deadcode.actions.fix_or_show_unused_code.os.remove')
|
|
50
|
+
|
|
51
|
+
self.args = Args()
|
|
52
|
+
|
|
53
|
+
def assertFiles(self, files: Dict[str, bytes], removed: Optional[List[str]] = None) -> None:
|
|
54
|
+
expected_removed_files = removed
|
|
55
|
+
expected_files = files
|
|
56
|
+
|
|
57
|
+
# Check if removed files match
|
|
58
|
+
removed_filenames = [call.args[0] for call in self.os_remove.mock_calls]
|
|
59
|
+
if expected_removed_files:
|
|
60
|
+
self.assertEqual(removed_filenames, expected_removed_files)
|
|
61
|
+
|
|
62
|
+
# Check if non removed files are the same
|
|
63
|
+
unchanged_files = {
|
|
64
|
+
filename: file_content
|
|
65
|
+
for filename, file_content in self.files.items()
|
|
66
|
+
if filename not in self.updated_files and filename not in removed_filenames
|
|
67
|
+
}
|
|
68
|
+
self.assertListEqual(
|
|
69
|
+
list(expected_files.keys()), list(self.updated_files.keys()) + list(unchanged_files.keys())
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Check if non removed file contents match (both changed and unchanged)
|
|
73
|
+
for filename, content in expected_files.items():
|
|
74
|
+
self.assertEqual(
|
|
75
|
+
fix_indent(content),
|
|
76
|
+
fix_indent(self.updated_files.get(filename) or unchanged_files.get(filename) or ''),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def assertUpdatedFiles(self, expected_updated_files: Dict[str, bytes]) -> None:
|
|
80
|
+
"""Checks if updated files match expected updated files."""
|
|
81
|
+
|
|
82
|
+
self.assertListEqual(list(expected_updated_files.keys()), list(self.updated_files.keys()))
|
|
83
|
+
|
|
84
|
+
for filename, content in expected_updated_files.items():
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
fix_indent(content),
|
|
87
|
+
self.updated_files[filename],
|
|
88
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar('T')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def fix_indent(doc: T) -> Optional[T]:
|
|
8
|
+
"""Finds indentation of a first line and removes it from all following lines.
|
|
9
|
+
|
|
10
|
+
Implemented based on inspect.cleandoc by keeping trailing lines.
|
|
11
|
+
"""
|
|
12
|
+
if isinstance(doc, bytes):
|
|
13
|
+
try:
|
|
14
|
+
byte_lines = doc.expandtabs().split(b'\n')
|
|
15
|
+
except UnicodeError:
|
|
16
|
+
return None
|
|
17
|
+
else:
|
|
18
|
+
# Find minimum indentation of any non-blank lines after first line.
|
|
19
|
+
margin = sys.maxsize
|
|
20
|
+
for byte_line in byte_lines[0:]:
|
|
21
|
+
content = len(byte_line.lstrip())
|
|
22
|
+
if content:
|
|
23
|
+
indent = len(byte_line) - content
|
|
24
|
+
margin = min(margin, indent)
|
|
25
|
+
# Remove indentation.
|
|
26
|
+
if byte_lines:
|
|
27
|
+
byte_lines[0] = byte_lines[0].lstrip()
|
|
28
|
+
if margin < sys.maxsize:
|
|
29
|
+
for i in range(1, len(byte_lines)):
|
|
30
|
+
byte_lines[i] = byte_lines[i][margin:]
|
|
31
|
+
|
|
32
|
+
## Do not remove trailing lines
|
|
33
|
+
# while lines and not lines[-1]:
|
|
34
|
+
# lines.pop()
|
|
35
|
+
|
|
36
|
+
## Remove any leading blank lines.
|
|
37
|
+
while byte_lines and not byte_lines[0]:
|
|
38
|
+
byte_lines.pop(0)
|
|
39
|
+
return b'\n'.join(byte_lines) # type: ignore
|
|
40
|
+
|
|
41
|
+
if isinstance(doc, str):
|
|
42
|
+
try:
|
|
43
|
+
lines = doc.expandtabs().split('\n')
|
|
44
|
+
except UnicodeError:
|
|
45
|
+
return None
|
|
46
|
+
else:
|
|
47
|
+
# Find minimum indentation of any non-blank lines after first line.
|
|
48
|
+
margin = sys.maxsize
|
|
49
|
+
for line in lines[0:]:
|
|
50
|
+
content = len(line.lstrip())
|
|
51
|
+
if content:
|
|
52
|
+
indent = len(line) - content
|
|
53
|
+
margin = min(margin, indent)
|
|
54
|
+
# Remove indentation.
|
|
55
|
+
if lines:
|
|
56
|
+
lines[0] = lines[0].lstrip()
|
|
57
|
+
if margin < sys.maxsize:
|
|
58
|
+
for i in range(1, len(lines)):
|
|
59
|
+
lines[i] = lines[i][margin:]
|
|
60
|
+
|
|
61
|
+
## Do not remove trailing lines
|
|
62
|
+
# while lines and not lines[-1]:
|
|
63
|
+
# lines.pop()
|
|
64
|
+
|
|
65
|
+
## Remove any leading blank lines.
|
|
66
|
+
while lines and not lines[0]:
|
|
67
|
+
lines.pop(0)
|
|
68
|
+
return '\n'.join(lines) # type: ignore
|
|
69
|
+
|
|
70
|
+
return None
|