deadcode 0.0.1__tar.gz → 0.0.2__tar.gz

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.
@@ -1,7 +1,9 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: deadcode
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Find and remove dead code.
5
+ Project-URL: Homepage, https://github.com/albertas/deadcode
6
+ Project-URL: Documentation, https://deadcode.readthedocs.io/
5
7
  Author-email: Albertas Gimbutas <albertasgim@gmail.com>
6
8
  Classifier: Development Status :: 3 - Alpha
7
9
  Classifier: License :: OSI Approved :: GNU Affero General Public License v3
@@ -0,0 +1,27 @@
1
+ import argparse
2
+ from typing import List, Optional
3
+ import sys
4
+
5
+ from deadcode.data_types import Args
6
+ from deadcode.utils import split_comma_separated_values
7
+
8
+
9
+ def parse_command_line_arguments(args: Optional[List[str]]) -> Args:
10
+ if not args:
11
+ args = sys.argv[1:]
12
+
13
+ parser = argparse.ArgumentParser() # Docs: https://docs.python.org/3/library/argparse.html
14
+ parser.add_argument("paths", help="Paths where to search for python files", nargs="+")
15
+ parser.add_argument("--exclude", help="PATHS to files not to analyse at all (comma separated values)",
16
+ nargs="*", action="append", default=[], type=str)
17
+ parser.add_argument("--ignore-names", help="NAMES not to report (comma separated values)",
18
+ nargs="*", action="append", default=[], type=str)
19
+ parser.add_argument("--ignore-files", help="PATHS to files not to report NAMES from (comma separated values)",
20
+ nargs="*", action="append", default=[], type=str)
21
+
22
+ parsed_args = parser.parse_args(args).__dict__
23
+
24
+ for field_name in ["exclude", "ignore_names", "ignore_files"]:
25
+ parsed_args[field_name] = split_comma_separated_values(parsed_args.get(field_name))
26
+
27
+ return Args(**parsed_args)
@@ -0,0 +1,138 @@
1
+ from typing import Dict, List, Optional
2
+ from collections import defaultdict
3
+ import os
4
+ import sys
5
+ import re
6
+ from fnmatch import fnmatch
7
+
8
+ from deadcode.data_types import Args, FileContent, Filename, Pathname, VariableName
9
+ from deadcode.actions.argument_parsing import parse_command_line_arguments
10
+
11
+
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
+
19
+
20
+ def find_python_filenames(args: Args):
21
+ """Recursively searches for Python filenames in provided paths."""
22
+ python_filenames = []
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)
123
+
124
+ python_filenames = find_python_filenames(args=args)
125
+ files = read_files(python_filenames)
126
+ files_without_comments = remove_comments(files)
127
+
128
+ global_names = parse_global_names(files, args=args)
129
+ unused_names = find_unused_names(files_without_comments, global_names, args=args)
130
+
131
+ print_unused_names(unused_names)
132
+ if unused_names:
133
+ sys.exit(1)
134
+ return unused_names
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+
5
+ FileContent = str
6
+ Filename = str # Contains full path to existing file
7
+ Pathname = str # Can contain wildewards
8
+ VariableName = str
9
+
10
+
11
+ @dataclass
12
+ class Args:
13
+ paths: List[Pathname]
14
+ exclude: List[Pathname]
15
+ ignore_names: List[Pathname]
16
+ ignore_files: List[Pathname]
@@ -19,4 +19,3 @@ class UnusedClass(object):
19
19
  class AnotherUnusedClass:
20
20
  def __init__(self):
21
21
  pass
22
-
@@ -1,4 +1,2 @@
1
-
2
1
  def another_unused_function():
3
2
  pass
4
-
@@ -0,0 +1,76 @@
1
+ from subprocess import PIPE, Popen
2
+ from unittest import TestCase
3
+ from unittest.mock import patch
4
+
5
+ from deadcode.cli import main
6
+ from deadcode.actions.argument_parsing import parse_command_line_arguments
7
+
8
+
9
+ class FindDeadCodeTests(TestCase):
10
+ @patch("deadcode.cli.sys")
11
+ def test_run_dead_code_finder_with_a_subprocess_in_a_right_directory_main(self, sys_mock):
12
+ unused_names = main(["tests/test_case1"])
13
+ self.assertEqual(
14
+ unused_names,
15
+ {
16
+ "ANOTHER_GLOBAL_VARIABLE": "tests/test_case1/hello_world.py:4",
17
+ "AnotherUnusedClass": "tests/test_case1/hello_world.py:19",
18
+ "UNUSED_VARIABLE": "tests/test_case1/subdir/another_subdir/__init__.py:1",
19
+ "another_unused_function": "tests/test_case1/subdir/one_more_file.py:1",
20
+ "unused_global_variable": "tests/test_case1/hello_world.py:3",
21
+ },
22
+ )
23
+ sys_mock.exit.assert_called_once_with(1)
24
+
25
+ ## TODO: Add test for . path
26
+ # def test_using_dot_to_find_files_recursively(self):
27
+ # unused_names = main([".", "--exclude=tests,venv"])
28
+
29
+ # self.assertEqual(
30
+ # unused_names, {
31
+ # 'not_used_function': './tests/module_a.py:5',
32
+ # 'FindDeadCodeTests': './tests/test_deadcode.py:7',
33
+ # # "ANOTHER_GLOBAL_VARIABLE": "tests/test_case1/hello_world.py:4",
34
+ # # "AnotherUnusedClass": "tests/test_case1/hello_world.py:19",
35
+ # # "UNUSED_VARIABLE": "tests/test_case1/subdir/another_subdir/__init__.py:1",
36
+ # # "another_unused_function": "tests/test_case1/subdir/one_more_file.py:1",
37
+ # # "unused_global_variable": "tests/test_case1/hello_world.py:3",
38
+ # },
39
+ # )
40
+
41
+
42
+ class CommandLineArgParsingTests(TestCase):
43
+ def test_calling_with_one_paths_argument(self):
44
+ args = parse_command_line_arguments(["."])
45
+ self.assertEqual(args.paths, ["."])
46
+ self.assertEqual(args.exclude, [])
47
+ self.assertEqual(args.ignore_names, [])
48
+ self.assertEqual(args.ignore_files, [])
49
+
50
+ def test_calling_with_several_paths_argument(self):
51
+ args = parse_command_line_arguments([".", "tests"])
52
+ self.assertEqual(args.paths, [".", "tests"])
53
+ self.assertEqual(args.exclude, [])
54
+ self.assertEqual(args.ignore_names, [])
55
+ self.assertEqual(args.ignore_files, [])
56
+
57
+ def test_calling_with_exclude(self):
58
+ args = parse_command_line_arguments([".", "--exclude=tests,venv"])
59
+ self.assertEqual(args.paths, ["."])
60
+ self.assertEqual(args.exclude, ["tests", "venv"])
61
+ self.assertEqual(args.ignore_names, [])
62
+ self.assertEqual(args.ignore_files, [])
63
+
64
+ def test_calling_with_several_exclude_options(self):
65
+ args = parse_command_line_arguments([".", "--exclude=tests,venv", "--exclude=migrations"])
66
+ self.assertEqual(args.paths, ["."])
67
+ self.assertEqual(args.exclude, ["tests", "venv", "migrations"])
68
+ self.assertEqual(args.ignore_names, [])
69
+ self.assertEqual(args.ignore_files, [])
70
+
71
+ def test_ignore_names_and_ignore_files_command_line_argument_parsing(self):
72
+ args = parse_command_line_arguments([".", "--ignore-files=tests,venv", "--ignore-names=BaseTestCase,lambda_handler"])
73
+ self.assertEqual(args.paths, ["."])
74
+ self.assertEqual(args.exclude, [])
75
+ self.assertEqual(args.ignore_names, ["BaseTestCase", "lambda_handler"])
76
+ self.assertEqual(args.ignore_files, ["tests", "venv"])
@@ -0,0 +1,16 @@
1
+ from typing import List, Optional
2
+
3
+
4
+ def split_comma_separated_values(list_of_comma_separated_values: Optional[List[str]]) -> List[str]:
5
+ """Concatenates lists into one list."""
6
+ if not list_of_comma_separated_values:
7
+ return []
8
+ list_of_comma_separated_values = flatten_list(list_of_comma_separated_values)
9
+ return flatten_list([v.split(",") for v in list_of_comma_separated_values])
10
+
11
+
12
+ def flatten_list(list_of_lists: Optional[List[List[str]]]) -> List[str]:
13
+ """Concatenates lists into one list."""
14
+ if not list_of_lists:
15
+ return []
16
+ return [elem for elem_list in list_of_lists for elem in elem_list]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deadcode"
3
- version = "0.0.1"
3
+ version = "0.0.2"
4
4
  authors = [
5
5
  {name = "Albertas Gimbutas", email = "albertasgim@gmail.com"},
6
6
  ]
@@ -22,18 +22,28 @@ dependencies = [
22
22
  ]
23
23
 
24
24
  [project.scripts]
25
- deadcode = "dead_code.cli:main"
25
+ deadcode = "deadcode.cli:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/albertas/deadcode"
29
+ Documentation = "https://deadcode.readthedocs.io/"
26
30
 
27
31
  [build-system]
28
32
  requires = ["hatchling"]
29
33
  build-backend = "hatchling.build"
30
34
 
31
- [tool.hatch.publish.index]
35
+
36
+ [tool.hatch.publish.index] # Hatch docs: https://hatch.pypa.io/latest/config/build/
32
37
  disable = true
33
38
 
39
+ [tool.hatch.build]
40
+ include = ["**/*.py"]
41
+ exclude = ["tests/**"]
42
+
34
43
  [tool.mypy]
35
44
  exclude = ["build", "dist", "venv"]
36
45
  python_version = "3.8"
46
+ strict = true
37
47
  pretty = true
38
48
  color_output = true
39
49
  show_error_codes = true
@@ -43,13 +53,8 @@ warn_unused_configs = true
43
53
  [tool.isort]
44
54
  profile = "black"
45
55
  multi_line_output = 3
56
+ known_third_party = ["deadcode"]
57
+
58
+
59
+
46
60
 
47
- # https://github.com/PyCQA/flake8/issues/234
48
- # [tool.flake8]
49
- # inline-quotes = "double"
50
- # exclude = ["venv"]
51
- # max-line-length = 120
52
- # max-complexity = 10
53
- # max-function-length= 120
54
- # import-order-style = "google"
55
- # ignore = "I101,"
deadcode-0.0.1/.flake8 DELETED
@@ -1,9 +0,0 @@
1
- [flake8]
2
- exclude = build dist venv
3
- inline-quotes = double
4
- jobs = 4
5
- application-import-names = dead_code_finder
6
- max-complexity = 10
7
- max-function-length= 120
8
- max-line-length = 120
9
- ignore = T201 I001
deadcode-0.0.1/Makefile DELETED
@@ -1,23 +0,0 @@
1
- env: venv
2
- venv/bin/pip install --upgrade pip
3
- venv/bin/pip install -r requirements-dev.txt --use-pep517
4
- venv/bin/pip install -e .
5
-
6
- venv:
7
- python3.9 -m venv venv
8
-
9
- publish: venv
10
- rm -fr dist/*
11
- venv/bin/hatch build
12
- venv/bin/hatch publish
13
-
14
- flake8:
15
- venv/bin/flake8 dead_code_finder
16
-
17
- mypy:
18
- venv/bin/mypy dead_code_finder
19
-
20
- test:
21
- venv/bin/pytest $(PYTEST_ME_PLEASE)
22
-
23
- check: test flake8 mypy
@@ -1,10 +0,0 @@
1
- flake8:
2
- ../venv/bin/flake8 .
3
-
4
- mypy:
5
- ../venv/bin/mypy .
6
-
7
- test:
8
- ../venv/bin/pytest -s $(PYTEST_ME_PLEASE)
9
-
10
- check: test flake8 mypy
@@ -1,78 +0,0 @@
1
- from typing import Dict, List, Optional
2
- from collections import defaultdict
3
- import os
4
- import sys
5
- import re
6
-
7
-
8
- FileContent = str
9
- Filename = str
10
- Pathname = str
11
- VariableName = str
12
-
13
-
14
- def find_python_filenames(paths: List[Pathname]):
15
- """Recursively searches for Python filenames in provided paths."""
16
- filenames = []
17
- for path in paths:
18
- if os.path.isfile(path):
19
- if os.path.splitext(path)[1] == ".py":
20
- filenames.append(path)
21
- else:
22
- filenames += [os.path.join(dp, f) for dp, dn, filenames in os.walk(path)
23
- for f in filenames if os.path.splitext(f)[1] == ".py"]
24
- return filenames
25
-
26
-
27
- def read_files(filenames: List[Filename]) -> Dict[Filename, FileContent]:
28
- files = {}
29
- for filename in filenames:
30
- with open(filename, "r") as f:
31
- files[filename] = f.read()
32
- return files
33
-
34
-
35
- def parse_global_names(files: Dict[Filename, FileContent]) -> Dict[VariableName, Filename]:
36
- patterns = [r"^(\w+)\s*=", r"^def\s+(\w+)\s*\(?", r"^class\s+(\w+)\s*\(?:?"]
37
-
38
- global_variable_names = {}
39
- for filename, file_content in files.items():
40
- for pattern in patterns:
41
- global_variable_names_in_file = re.findall(pattern, file_content, re.MULTILINE)
42
- for name in global_variable_names_in_file:
43
- global_variable_names[name] = filename
44
- return global_variable_names
45
-
46
-
47
- def find_unused_names(files: Dict[str, str], global_names: Dict[VariableName, Filename]
48
- ) -> Dict[VariableName, Filename]:
49
- name_occourencies = defaultdict(int)
50
- for name in global_names.keys():
51
- for file_content in files.values():
52
- name_occourencies[name] += file_content.count(name)
53
-
54
- unused_variables = {name: filename for name, filename in global_names.items() if name_occourencies[name] == 1}
55
- return unused_variables
56
-
57
-
58
- def print_unused_names(unused_names: Dict[VariableName, Filename]):
59
- # TODO: add line number to output
60
- print()
61
- for name, filename in unused_names.items():
62
- print(filename, "\033[91mF851\033[0m", f"Global \033[1m{name}\033[0m is never used")
63
-
64
-
65
- def main(args: Optional[List[str]] = None) -> None:
66
- args = args or sys.argv[1:]
67
-
68
- python_filenames = find_python_filenames(paths=args)
69
- files = read_files(python_filenames)
70
-
71
- global_names = parse_global_names(files)
72
- unused_names = find_unused_names(files, global_names)
73
-
74
- print_unused_names(unused_names)
75
-
76
-
77
- if __name__ == "__main__":
78
- main()
@@ -1,17 +0,0 @@
1
- from subprocess import PIPE, Popen
2
- from unittest import TestCase
3
-
4
- from dead_code.cli import main
5
-
6
-
7
- class DeadCodeTests(TestCase):
8
- def test_main(self):
9
- self.assertIsNone(main())
10
-
11
-
12
- class DetectUnimportedDefinitionInRoot(TestCase):
13
- def test_run_dead_code_finder_with_a_subprocess_in_a_right_directory_main(self):
14
- main(["tests/test_case1"])
15
-
16
- # def testrun_dead_code_finder_with_a_subprocess_in_a_right_directory(self):
17
- # result = Popen(["../venv/bin/dead-code-finder", "tests/test_case1"], stdout=PIPE).communicate()[0].decode()
deadcode-0.0.1/notes DELETED
@@ -1,88 +0,0 @@
1
- ##################
2
- ### Aggreed plan ##########################################
3
- # Create a pip package with a good name, write some tests #
4
- ###########################################################
5
- #
6
- # [2023-06-05]
7
- # Solution: load all the files
8
- # Check accourance count of the definition in all the files (if more than 1) - is used.
9
- # Can remove string and comments before checking.
10
- #
11
- # Subtasks:
12
- # expand provided file paths
13
- # read all files into memory (filename, content)
14
- # is string in for 5k file a long operation?
15
- # Should use a counter instead.
16
- #
17
- # Check SolarCube checks and Ruff if it detects such cases
18
- # Check Vulture for false positives with Mainhive repository.
19
-
20
-
21
-
22
- #
23
-
24
- SonarQube and ide linters: do they detect unused class definitions in other files?
25
- #############
26
-
27
- List of imported objects in dot separated form (how does import as work for this?):
28
- sys.modules.keys()
29
- #############
30
-
31
-
32
- Go through files and detect, what has been defined in those files and whether all definitions are used.
33
- entry point: lambdas_handler.py
34
-
35
-
36
-
37
- Get Tree Representation of Loaded Python Modules (Python recipe)
38
- https://code.activestate.com/recipes/442478-get-tree-representation-of-loaded-python-modules/
39
-
40
-
41
- from xml.dom.minidom import Document
42
-
43
- def get_tree_representation_of_loaded_python_modules(self):
44
- modules = sys.modules.keys()
45
- modules.sort()
46
-
47
- tree_data = Document()
48
- node = tree_data.createElement('li')
49
- text = tree_data.createTextNode('Loaded Python Modules')
50
- node.appendChild(text)
51
- tree_data.appendChild(node)
52
- map = {}
53
- for module in modules:
54
- ms = module.split('.')
55
- path = node
56
- for level in range(len(ms)):
57
- nodes = map.get(level)
58
- if nodes == None:
59
- node1 = tree_data.createElement('ul')
60
- node2 = tree_data.createElement('li')
61
- textNode = tree_data.createTextNode(ms[level])
62
- node2.appendChild(textNode)
63
- node1.appendChild(node2)
64
- path.appendChild(node1)
65
- nodes = [node2]
66
- map[level] = nodes
67
- else:
68
- node2 = None
69
- for tmpNode in nodes:
70
- if tmpNode.firstChild.data == ms[level]:
71
- node2 = tmpNode
72
- node1 = node2.parentNode
73
- if node2 == None:
74
- node1 = path.childNodes.item(1)
75
- if node1==None:
76
- node1 = tree_data.createElement('ul')
77
- path.appendChild(node1)
78
- node2 = tree_data.createElement('li')
79
- textNode = tree_data.createTextNode(ms[level])
80
- node2.appendChild(textNode)
81
- node1.appendChild(node2)
82
- nodes += [node2]
83
- map[level] = nodes
84
- path = node2
85
-
86
- str = tree_data.toprettyxml()
87
- str = str[len('<?xml version="1.0" ?>')+1:]
88
- return str
@@ -1,30 +0,0 @@
1
- -r requirements.txt
2
-
3
- cognitive-complexity==1.3.0
4
- flake8==5.0.4
5
- flake8-annotations-complexity==0.0.7
6
- flake8-bandit==4.1.1
7
- flake8-blind-except==0.2.1
8
- flake8-breakpoint==1.1.0
9
- flake8-bugbear==22.10.27
10
- flake8-builtins==2.0.1
11
- flake8-cognitive-complexity==0.1.0
12
- flake8-comprehensions==3.10.1
13
- flake8-eradicate==1.4.0
14
- flake8-expression-complexity==0.0.11
15
- flake8-functions==0.0.7
16
- flake8-import-order==0.18.1
17
- flake8-isort==5.0.0
18
- flake8-logging-format==0.8.1
19
- flake8-print==5.0.0
20
- flake8-quotes==3.3.1
21
- flake8-variables-names==0.0.5
22
- isort==5.10.1
23
- mypy==0.990
24
- mypy-extensions==0.4.3
25
- pep8-naming==0.13.2
26
- pylint==2.15.5
27
- pytest==7.2.0
28
- vulture==2.6
29
- hatch==1.6.3
30
- hatchling==1.11.1
File without changes
File without changes