deadcode 0.0.2__py3-none-any.whl → 0.1.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.
- deadcode/actions/find_python_filenames.py +28 -0
- deadcode/actions/find_unused_names.py +26 -0
- deadcode/actions/get_unused_names_error_message.py +19 -0
- deadcode/actions/parse_abstract_syntax_trees.py +16 -0
- deadcode/actions/parse_arguments.py +88 -0
- deadcode/actions/parse_global_names.py +33 -0
- deadcode/actions/read_files.py +11 -0
- deadcode/cli.py +21 -124
- deadcode/data_types.py +5 -2
- deadcode/tests/files/classes.py +15 -0
- deadcode/tests/files/functions.py +18 -0
- deadcode/tests/files/variables.py +8 -0
- deadcode/tests/test_deadcode.py +166 -44
- deadcode/utils/__init__.py +0 -0
- deadcode/{utils.py → utils/flatten_lists.py} +5 -3
- deadcode/utils/path_matching.py +17 -0
- deadcode-0.1.0.dist-info/METADATA +73 -0
- deadcode-0.1.0.dist-info/RECORD +25 -0
- deadcode-0.1.0.dist-info/licenses/LICENSE +661 -0
- deadcode/actions/argument_parsing.py +0 -27
- deadcode/tests/module_a.py +0 -6
- deadcode/tests/module_b.py +0 -5
- deadcode/tests/test_case1/hello_world.py +0 -21
- deadcode-0.0.2.dist-info/METADATA +0 -26
- deadcode-0.0.2.dist-info/RECORD +0 -16
- /deadcode/tests/{test_case1 → files}/subdir/another_subdir/__init__.py +0 -0
- /deadcode/tests/{test_case1 → files}/subdir/one_more_file.py +0 -0
- {deadcode-0.0.2.dist-info → deadcode-0.1.0.dist-info}/WHEEL +0 -0
- {deadcode-0.0.2.dist-info → deadcode-0.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from deadcode.data_types import Args
|
|
4
|
+
from deadcode.utils.path_matching import does_path_match_patterns
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_python_filenames(args: Args):
|
|
8
|
+
"""Recursively searches for Python filenames in provided paths."""
|
|
9
|
+
python_filenames = []
|
|
10
|
+
for path in args.paths:
|
|
11
|
+
if does_path_match_patterns(path, args.exclude):
|
|
12
|
+
continue
|
|
13
|
+
|
|
14
|
+
if os.path.isfile(path):
|
|
15
|
+
filename = path
|
|
16
|
+
if os.path.splitext(filename)[1] == ".py":
|
|
17
|
+
python_filenames.append(filename)
|
|
18
|
+
else:
|
|
19
|
+
for path, _, filenames in os.walk(path):
|
|
20
|
+
if does_path_match_patterns(path, args.exclude):
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
for f in filenames:
|
|
24
|
+
filename = os.path.join(path, f)
|
|
25
|
+
if os.path.splitext(filename)[1] == ".py" and not does_path_match_patterns(filename, args.exclude):
|
|
26
|
+
python_filenames.append(filename)
|
|
27
|
+
|
|
28
|
+
return python_filenames
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from deadcode.data_types import Args, Filename, VariableName, AbstractSyntaxTree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_unused_names(
|
|
9
|
+
files: Dict[Filename, AbstractSyntaxTree],
|
|
10
|
+
global_names: Dict[VariableName, Filename],
|
|
11
|
+
args: Args,
|
|
12
|
+
) -> Dict[VariableName, Filename]:
|
|
13
|
+
name_occourencies: Dict[str, int] = defaultdict(int)
|
|
14
|
+
for name in global_names.keys():
|
|
15
|
+
pattern = re.compile(
|
|
16
|
+
rf"FunctionDef\(name=\'{name}\'|ClassDef\(name=\'{name}\'|Name\(id=\'{name}\'|alias\(name=\'{name}\'"
|
|
17
|
+
)
|
|
18
|
+
for abstract_syntax_tree in files.values():
|
|
19
|
+
name_occourencies[name] += len(re.findall(pattern, abstract_syntax_tree))
|
|
20
|
+
|
|
21
|
+
unused_variables = {
|
|
22
|
+
name: filename
|
|
23
|
+
for name, filename in global_names.items()
|
|
24
|
+
if name_occourencies[name] == 1 and name not in args.ignore_names
|
|
25
|
+
}
|
|
26
|
+
return unused_variables
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
|
|
3
|
+
from deadcode.data_types import Filename, VariableName
|
|
4
|
+
from deadcode.data_types import Args
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_unused_names_error_message(unused_names: Dict[VariableName, Filename], args: Args) -> Optional[str]:
|
|
8
|
+
if not unused_names:
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
if args.no_color:
|
|
12
|
+
return "\n".join([f"{filename} DC100 Global {name} is never used" for name, filename in unused_names.items()])
|
|
13
|
+
|
|
14
|
+
return "\n".join(
|
|
15
|
+
[
|
|
16
|
+
f"{filename} \033[91mDC100\033[0m Global \033[1m{name}\033[0m is never used"
|
|
17
|
+
for name, filename in unused_names.items()
|
|
18
|
+
]
|
|
19
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from deadcode.data_types import FileContent, Filename, AbstractSyntaxTree
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_abstract_syntax_trees(files: Dict[Filename, FileContent]) -> Dict[Filename, AbstractSyntaxTree]:
|
|
8
|
+
"""Did my code coverage just increased? Answer is no: a doc string was ignored :tada:"""
|
|
9
|
+
abstract_syntax_trees_for_files = {}
|
|
10
|
+
for filename, file_content in files.items():
|
|
11
|
+
try:
|
|
12
|
+
abstract_syntax_trees_for_files[filename] = ast.dump(ast.parse(file_content)) # Note: indent=1 for debug
|
|
13
|
+
except: # noqa: E722
|
|
14
|
+
print(f"Error: Failed to parse {filename} file, ignoring it.")
|
|
15
|
+
abstract_syntax_trees_for_files[filename] = ""
|
|
16
|
+
return abstract_syntax_trees_for_files
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
if sys.version_info >= (3, 11):
|
|
7
|
+
import tomllib
|
|
8
|
+
else:
|
|
9
|
+
import tomli as tomllib
|
|
10
|
+
|
|
11
|
+
from deadcode.data_types import Args
|
|
12
|
+
from deadcode.utils.flatten_lists import flatten_lists_of_comma_separated_values
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_arguments(args: Optional[List[str]]) -> Args:
|
|
16
|
+
"""Parses arguments (execution options) for deadcode tool.
|
|
17
|
+
|
|
18
|
+
Arguments for DeadCode can be provided via:
|
|
19
|
+
- via command line
|
|
20
|
+
- via pyproject.toml
|
|
21
|
+
- via function arguments
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
if not args:
|
|
25
|
+
args = sys.argv[1:]
|
|
26
|
+
|
|
27
|
+
# Docs: https://docs.python.org/3/library/argparse.html
|
|
28
|
+
parser = argparse.ArgumentParser()
|
|
29
|
+
parser.add_argument("paths", help="Paths where to search for python files", nargs="+")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--exclude",
|
|
32
|
+
help="PATHS to files not to analyse at all (comma separated values)",
|
|
33
|
+
nargs="*",
|
|
34
|
+
action="append",
|
|
35
|
+
default=[],
|
|
36
|
+
type=str,
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--ignore-names",
|
|
40
|
+
help="NAMES not to report (comma separated values)",
|
|
41
|
+
nargs="*",
|
|
42
|
+
action="append",
|
|
43
|
+
default=[],
|
|
44
|
+
type=str,
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--ignore-names-in-files",
|
|
48
|
+
help="PATHS to files not to report NAMES from (comma separated values)",
|
|
49
|
+
nargs="*",
|
|
50
|
+
action="append",
|
|
51
|
+
default=[],
|
|
52
|
+
type=str,
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--no-color",
|
|
56
|
+
help="Turn off colors in the output",
|
|
57
|
+
action="store_true",
|
|
58
|
+
default=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parsed_args = parser.parse_args(args).__dict__
|
|
62
|
+
|
|
63
|
+
for field_name in ["exclude", "ignore_names", "ignore_names_in_files"]:
|
|
64
|
+
parsed_args[field_name] = flatten_lists_of_comma_separated_values(parsed_args.get(field_name))
|
|
65
|
+
|
|
66
|
+
# Extend the Args with the values provided in the pyproject.toml
|
|
67
|
+
for key, item in parse_pyproject_toml().items():
|
|
68
|
+
if key in parsed_args:
|
|
69
|
+
parsed_args[key].extend(item)
|
|
70
|
+
|
|
71
|
+
return Args(**parsed_args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_pyproject_toml() -> Dict[str, Any]:
|
|
75
|
+
"""Parse a pyproject toml file, pulling out relevant parts for Black.
|
|
76
|
+
|
|
77
|
+
If parsing fails, will raise a tomllib.TOMLDecodeError.
|
|
78
|
+
Copied from: https://github.com/psf/black/blob/01b8d3d4095ebdb91d0d39012a517931625c63cb/src/black/files.py#LL113C15-L113C15
|
|
79
|
+
"""
|
|
80
|
+
pyproject_toml_filename = "pyproject.toml"
|
|
81
|
+
if not os.path.isfile(pyproject_toml_filename):
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
with open(pyproject_toml_filename, "rb") as f:
|
|
85
|
+
pyproject_toml = tomllib.load(f)
|
|
86
|
+
config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("deadcode", {})
|
|
87
|
+
config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
|
|
88
|
+
return config
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from deadcode.utils.path_matching import does_path_match_patterns
|
|
5
|
+
from deadcode.data_types import (
|
|
6
|
+
Args,
|
|
7
|
+
FileContent,
|
|
8
|
+
Filename,
|
|
9
|
+
VariableName,
|
|
10
|
+
FilenameWithPosition,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_global_names(files: Dict[Filename, FileContent], args: Args) -> Dict[VariableName, FilenameWithPosition]:
|
|
15
|
+
patterns = [
|
|
16
|
+
re.compile(r"^(\w+)\s*="),
|
|
17
|
+
re.compile(r"^def\s+(\w+)\s*\(?"),
|
|
18
|
+
re.compile(r"^class\s+(\w+)\s*\(?:?"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
global_variable_names = {}
|
|
22
|
+
for filename, file_content in files.items():
|
|
23
|
+
if does_path_match_patterns(filename, args.ignore_names_in_files):
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
for pattern in patterns:
|
|
27
|
+
for line_nr, line in enumerate(file_content.split("\n"), 1):
|
|
28
|
+
if global_variable_name_match := re.search(pattern, line):
|
|
29
|
+
variable_name = global_variable_name_match.groups()[0]
|
|
30
|
+
column_nr = global_variable_name_match.regs[-1][0]
|
|
31
|
+
global_variable_names[variable_name] = f"{filename}:{line_nr}:{column_nr}:"
|
|
32
|
+
|
|
33
|
+
return global_variable_names
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Dict, List
|
|
2
|
+
|
|
3
|
+
from deadcode.data_types import FileContent, Filename
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_files(filenames: List[Filename]) -> Dict[Filename, FileContent]:
|
|
7
|
+
files = {}
|
|
8
|
+
for filename in filenames:
|
|
9
|
+
with open(filename, "r") as f:
|
|
10
|
+
files[filename] = f.read()
|
|
11
|
+
return files
|
deadcode/cli.py
CHANGED
|
@@ -1,137 +1,34 @@
|
|
|
1
|
-
from typing import
|
|
2
|
-
from collections import defaultdict
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
import re
|
|
6
|
-
from fnmatch import fnmatch
|
|
1
|
+
from typing import List, Optional
|
|
7
2
|
|
|
8
|
-
from deadcode.
|
|
9
|
-
from deadcode.actions.
|
|
3
|
+
from deadcode.actions.find_python_filenames import find_python_filenames
|
|
4
|
+
from deadcode.actions.find_unused_names import find_unused_names
|
|
5
|
+
from deadcode.actions.parse_arguments import parse_arguments
|
|
6
|
+
from deadcode.actions.parse_global_names import parse_global_names
|
|
7
|
+
from deadcode.actions.read_files import read_files
|
|
10
8
|
|
|
9
|
+
from deadcode.actions.parse_abstract_syntax_trees import parse_abstract_syntax_trees
|
|
10
|
+
from deadcode.actions.get_unused_names_error_message import (
|
|
11
|
+
get_unused_names_error_message,
|
|
12
|
+
)
|
|
11
13
|
|
|
12
|
-
def does_path_match_patterns(path: Pathname, patterns: Optional[List[Pathname]]) -> bool:
|
|
13
|
-
if path != "." and patterns:
|
|
14
|
-
for pattern in patterns:
|
|
15
|
-
if fnmatch(path, pattern):
|
|
16
|
-
return True
|
|
17
|
-
return False
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
for path in args.paths:
|
|
24
|
-
if does_path_match_patterns(path, args.exclude):
|
|
25
|
-
continue
|
|
26
|
-
|
|
27
|
-
if os.path.isfile(path):
|
|
28
|
-
filename = path
|
|
29
|
-
if os.path.splitext(filename)[1] == ".py":
|
|
30
|
-
python_filenames.append(filename)
|
|
31
|
-
else:
|
|
32
|
-
for path, _, filenames in os.walk(path):
|
|
33
|
-
if does_path_match_patterns(path, args.exclude):
|
|
34
|
-
continue
|
|
35
|
-
|
|
36
|
-
for f in filenames:
|
|
37
|
-
filename = os.path.join(path, f)
|
|
38
|
-
if (os.path.splitext(filename)[1] == ".py" and
|
|
39
|
-
not does_path_match_patterns(filename, args.exclude)):
|
|
40
|
-
python_filenames.append(filename)
|
|
41
|
-
|
|
42
|
-
return python_filenames
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def read_files(filenames: List[Filename]) -> Dict[Filename, FileContent]:
|
|
46
|
-
files = {}
|
|
47
|
-
for filename in filenames:
|
|
48
|
-
with open(filename, "r") as f:
|
|
49
|
-
files[filename] = f.read()
|
|
50
|
-
return files
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def parse_global_names(
|
|
54
|
-
files: Dict[Filename, FileContent], args: Args
|
|
55
|
-
) -> Dict[VariableName, Filename]:
|
|
56
|
-
patterns = [
|
|
57
|
-
r"^(\w+)\s*=",
|
|
58
|
-
r"^def\s+(\w+)\s*\(?",
|
|
59
|
-
r"^class\s+(\w+)\s*\(?:?",
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
global_variable_names = {}
|
|
63
|
-
for filename, file_content in files.items():
|
|
64
|
-
if does_path_match_patterns(filename, args.ignore_files):
|
|
65
|
-
continue
|
|
66
|
-
|
|
67
|
-
for pattern in patterns:
|
|
68
|
-
for line_nr, line in enumerate(file_content.split("\n"), 1):
|
|
69
|
-
if global_variable_name_in_file := re.findall(pattern, line):
|
|
70
|
-
variable_name = global_variable_name_in_file[0]
|
|
71
|
-
# TODO: could find the column name and add it to the output.
|
|
72
|
-
global_variable_names[variable_name] = f"{filename}:{line_nr}"
|
|
73
|
-
|
|
74
|
-
return global_variable_names
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def remove_comments_from_file_content(file_content: str) -> str:
|
|
78
|
-
# TODO: add a unit test for this feature.
|
|
79
|
-
file_lines_without_comments = []
|
|
80
|
-
for line in file_content:
|
|
81
|
-
if "#" in line:
|
|
82
|
-
# TODO: should ignore # in strings.
|
|
83
|
-
line = line.split("#", 1)[0]
|
|
84
|
-
file_lines_without_comments.append(line)
|
|
85
|
-
return "".join(file_lines_without_comments)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def remove_comments(files: Dict[Filename, FileContent]) -> Dict[Filename, FileContent]:
|
|
89
|
-
files_without_comments = {}
|
|
90
|
-
for filename, file_content in files.items():
|
|
91
|
-
files_without_comments[filename] = remove_comments_from_file_content(file_content)
|
|
92
|
-
return files_without_comments
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def find_unused_names(
|
|
96
|
-
files: Dict[str, str], global_names: Dict[VariableName, Filename], args: Args
|
|
97
|
-
) -> Dict[VariableName, Filename]:
|
|
98
|
-
name_occourencies = defaultdict(int)
|
|
99
|
-
for name in global_names.keys():
|
|
100
|
-
for file_content in files.values():
|
|
101
|
-
name_occourencies[name] += file_content.count(name)
|
|
102
|
-
|
|
103
|
-
unused_variables = {
|
|
104
|
-
name: filename
|
|
105
|
-
for name, filename in global_names.items()
|
|
106
|
-
if name_occourencies[name] == 1 and name not in args.ignore_names
|
|
107
|
-
}
|
|
108
|
-
return unused_variables
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def print_unused_names(unused_names: Dict[VariableName, Filename]):
|
|
112
|
-
# TODO: add line number to output
|
|
113
|
-
for name, filename in unused_names.items():
|
|
114
|
-
print(
|
|
115
|
-
filename,
|
|
116
|
-
"\033[91mF851\033[0m",
|
|
117
|
-
f"Global \033[1m{name}\033[0m is never used",
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def main(command_line_args: Optional[List[str]] = None) -> Dict[VariableName, Filename]:
|
|
122
|
-
args = parse_command_line_arguments(command_line_args)
|
|
15
|
+
def main(
|
|
16
|
+
command_line_args: Optional[List[str]] = None,
|
|
17
|
+
) -> Optional[str]:
|
|
18
|
+
args = parse_arguments(command_line_args)
|
|
123
19
|
|
|
124
20
|
python_filenames = find_python_filenames(args=args)
|
|
125
21
|
files = read_files(python_filenames)
|
|
126
|
-
files_without_comments = remove_comments(files)
|
|
127
22
|
|
|
128
23
|
global_names = parse_global_names(files, args=args)
|
|
129
|
-
|
|
24
|
+
abstract_syntax_trees_of_files = parse_abstract_syntax_trees(files)
|
|
25
|
+
unused_names = find_unused_names(abstract_syntax_trees_of_files, global_names, args=args)
|
|
26
|
+
|
|
27
|
+
if error_message := get_unused_names_error_message(unused_names, args=args):
|
|
28
|
+
return error_message
|
|
130
29
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
sys.exit(1)
|
|
134
|
-
return unused_names
|
|
30
|
+
print("\033[1mWell done!\033[0m ✨ 🚀 ✨")
|
|
31
|
+
return None
|
|
135
32
|
|
|
136
33
|
|
|
137
34
|
if __name__ == "__main__":
|
deadcode/data_types.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
-
from typing import List
|
|
2
|
+
from typing import List
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
AbstractSyntaxTree = str
|
|
5
6
|
FileContent = str
|
|
6
7
|
Filename = str # Contains full path to existing file
|
|
8
|
+
FilenameWithPosition = str # filename:line:column:
|
|
7
9
|
Pathname = str # Can contain wildewards
|
|
8
10
|
VariableName = str
|
|
9
11
|
|
|
@@ -13,4 +15,5 @@ class Args:
|
|
|
13
15
|
paths: List[Pathname]
|
|
14
16
|
exclude: List[Pathname]
|
|
15
17
|
ignore_names: List[Pathname]
|
|
16
|
-
|
|
18
|
+
ignore_names_in_files: List[Pathname]
|
|
19
|
+
no_color: bool
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
def unused_function():
|
|
2
|
+
if 2 > 1:
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def this_one_is_used():
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
this_one_is_used()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def another_unused_function(arg1: str = "", arg2: str = "Hello") -> None:
|
|
14
|
+
print(arg1, arg2)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# def this_method_is_commented():
|
|
18
|
+
# pass
|