skylos 2.0.0__py3-none-any.whl → 2.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.
Potentially problematic release.
This version of skylos might be problematic. Click here for more details.
- skylos/__init__.py +1 -1
- skylos/analyzer.py +48 -29
- skylos/cli.py +26 -79
- skylos/codemods.py +89 -0
- skylos/framework_aware.py +288 -94
- skylos/visitor.py +187 -42
- {skylos-2.0.0.dist-info → skylos-2.1.0.dist-info}/METADATA +4 -1
- {skylos-2.0.0.dist-info → skylos-2.1.0.dist-info}/RECORD +13 -11
- test/test_codemods.py +153 -0
- test/test_framework_aware.py +176 -242
- {skylos-2.0.0.dist-info → skylos-2.1.0.dist-info}/WHEEL +0 -0
- {skylos-2.0.0.dist-info → skylos-2.1.0.dist-info}/entry_points.txt +0 -0
- {skylos-2.0.0.dist-info → skylos-2.1.0.dist-info}/top_level.txt +0 -0
skylos/__init__.py
CHANGED
skylos/analyzer.py
CHANGED
|
@@ -6,7 +6,7 @@ import logging
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from collections import defaultdict
|
|
8
8
|
from skylos.visitor import Visitor
|
|
9
|
-
from skylos.constants import (
|
|
9
|
+
from skylos.constants import ( PENALTIES, AUTO_CALLED )
|
|
10
10
|
from skylos.test_aware import TestAwareVisitor
|
|
11
11
|
import os
|
|
12
12
|
import traceback
|
|
@@ -15,21 +15,6 @@ from skylos.framework_aware import FrameworkAwareVisitor, detect_framework_usage
|
|
|
15
15
|
logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(message)s')
|
|
16
16
|
logger=logging.getLogger('Skylos')
|
|
17
17
|
|
|
18
|
-
def parse_exclude_folders(user_exclude_folders, use_defaults=True, include_folders=None):
|
|
19
|
-
exclude_set = set()
|
|
20
|
-
|
|
21
|
-
if use_defaults:
|
|
22
|
-
exclude_set.update(DEFAULT_EXCLUDE_FOLDERS)
|
|
23
|
-
|
|
24
|
-
if user_exclude_folders:
|
|
25
|
-
exclude_set.update(user_exclude_folders)
|
|
26
|
-
|
|
27
|
-
if include_folders:
|
|
28
|
-
for folder in include_folders:
|
|
29
|
-
exclude_set.discard(folder)
|
|
30
|
-
|
|
31
|
-
return exclude_set
|
|
32
|
-
|
|
33
18
|
class Skylos:
|
|
34
19
|
def __init__(self):
|
|
35
20
|
self.defs={}
|
|
@@ -126,17 +111,31 @@ class Skylos:
|
|
|
126
111
|
for ref, _ in self.refs:
|
|
127
112
|
if ref in self.defs:
|
|
128
113
|
self.defs[ref].references += 1
|
|
129
|
-
|
|
130
114
|
if ref in import_to_original:
|
|
131
115
|
original = import_to_original[ref]
|
|
132
116
|
self.defs[original].references += 1
|
|
133
117
|
continue
|
|
134
|
-
|
|
118
|
+
|
|
135
119
|
simple = ref.split('.')[-1]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
120
|
+
ref_mod = ref.rsplit(".", 1)[0]
|
|
121
|
+
candidates = simple_name_lookup.get(simple, [])
|
|
122
|
+
|
|
123
|
+
if ref_mod:
|
|
124
|
+
filtered = []
|
|
125
|
+
for d in candidates:
|
|
126
|
+
if d.name.startswith(ref_mod + ".") and d.type != "import":
|
|
127
|
+
filtered.append(d)
|
|
128
|
+
candidates = filtered
|
|
129
|
+
else:
|
|
130
|
+
filtered = []
|
|
131
|
+
for d in candidates:
|
|
132
|
+
if d.type != "import":
|
|
133
|
+
filtered.append(d)
|
|
134
|
+
candidates = filtered
|
|
135
|
+
|
|
136
|
+
if len(candidates) == 1:
|
|
137
|
+
candidates[0].references += 1
|
|
138
|
+
|
|
140
139
|
for module_name in self.dynamic:
|
|
141
140
|
for def_name, def_obj in self.defs.items():
|
|
142
141
|
if def_obj.name.startswith(f"{module_name}."):
|
|
@@ -160,8 +159,8 @@ class Skylos:
|
|
|
160
159
|
confidence -= PENALTIES["private_name"]
|
|
161
160
|
if def_obj.simple_name.startswith("__") and def_obj.simple_name.endswith("__"):
|
|
162
161
|
confidence -= PENALTIES["dunder_or_magic"]
|
|
163
|
-
if def_obj.type == "variable" and def_obj.simple_name
|
|
164
|
-
confidence
|
|
162
|
+
if def_obj.type == "variable" and def_obj.simple_name.isupper():
|
|
163
|
+
confidence = 0
|
|
165
164
|
if def_obj.in_init and def_obj.type in ("function", "class"):
|
|
166
165
|
confidence -= PENALTIES["in_init_file"]
|
|
167
166
|
if def_obj.name.split(".")[0] in self.dynamic:
|
|
@@ -207,6 +206,14 @@ class Skylos:
|
|
|
207
206
|
for method in methods:
|
|
208
207
|
if method.simple_name in AUTO_CALLED:
|
|
209
208
|
method.references += 1
|
|
209
|
+
|
|
210
|
+
if (method.simple_name.startswith("visit_") or
|
|
211
|
+
method.simple_name.startswith("leave_") or
|
|
212
|
+
method.simple_name.startswith("transform_")):
|
|
213
|
+
method.references += 1
|
|
214
|
+
|
|
215
|
+
if method.simple_name == "format" and cls.endswith("Formatter"):
|
|
216
|
+
method.references += 1
|
|
210
217
|
|
|
211
218
|
def analyze(self, path, thr=60, exclude_folders=None):
|
|
212
219
|
files, root = self._get_python_files(path, exclude_folders)
|
|
@@ -246,8 +253,17 @@ class Skylos:
|
|
|
246
253
|
self._mark_refs()
|
|
247
254
|
self._apply_heuristics()
|
|
248
255
|
self._mark_exports()
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
|
|
257
|
+
shown = 0
|
|
258
|
+
|
|
259
|
+
def def_sort_key(d):
|
|
260
|
+
return (d.type, d.name)
|
|
261
|
+
|
|
262
|
+
for d in sorted(self.defs.values(), key=def_sort_key):
|
|
263
|
+
if shown >= 50:
|
|
264
|
+
break
|
|
265
|
+
print(f" {d.type:<8} refs={d.references:<2} conf={d.confidence:<3} exported={d.is_exported} line={d.line:<4} {d.name}")
|
|
266
|
+
shown += 1
|
|
251
267
|
|
|
252
268
|
unused = []
|
|
253
269
|
for definition in self.defs.values():
|
|
@@ -262,7 +278,7 @@ class Skylos:
|
|
|
262
278
|
"unused_parameters": [],
|
|
263
279
|
"analysis_summary": {
|
|
264
280
|
"total_files": len(files),
|
|
265
|
-
"excluded_folders": exclude_folders
|
|
281
|
+
"excluded_folders": exclude_folders or [],
|
|
266
282
|
}
|
|
267
283
|
}
|
|
268
284
|
|
|
@@ -295,7 +311,7 @@ def proc_file(file_or_args, mod=None):
|
|
|
295
311
|
|
|
296
312
|
fv = FrameworkAwareVisitor(filename=file)
|
|
297
313
|
fv.visit(tree)
|
|
298
|
-
|
|
314
|
+
fv.finalize()
|
|
299
315
|
v = Visitor(mod, file)
|
|
300
316
|
v.visit(tree)
|
|
301
317
|
|
|
@@ -322,7 +338,10 @@ if __name__ == "__main__":
|
|
|
322
338
|
print("\n Python Static Analysis Results")
|
|
323
339
|
print("===================================\n")
|
|
324
340
|
|
|
325
|
-
total_items =
|
|
341
|
+
total_items = 0
|
|
342
|
+
for key, items in data.items():
|
|
343
|
+
if key.startswith("unused_") and isinstance(items, list):
|
|
344
|
+
total_items += len(items)
|
|
326
345
|
|
|
327
346
|
print("Summary:")
|
|
328
347
|
if data["unused_functions"]:
|
skylos/cli.py
CHANGED
|
@@ -2,10 +2,14 @@ import argparse
|
|
|
2
2
|
import json
|
|
3
3
|
import sys
|
|
4
4
|
import logging
|
|
5
|
-
import ast
|
|
6
|
-
import skylos
|
|
7
5
|
from skylos.constants import parse_exclude_folders, DEFAULT_EXCLUDE_FOLDERS
|
|
8
6
|
from skylos.server import start_server
|
|
7
|
+
from skylos.analyzer import analyze as run_analyze
|
|
8
|
+
from skylos.codemods import (
|
|
9
|
+
remove_unused_import_cst,
|
|
10
|
+
remove_unused_function_cst,
|
|
11
|
+
)
|
|
12
|
+
import pathlib
|
|
9
13
|
|
|
10
14
|
try:
|
|
11
15
|
import inquirer
|
|
@@ -50,87 +54,30 @@ def setup_logger(output_file=None):
|
|
|
50
54
|
|
|
51
55
|
return logger
|
|
52
56
|
|
|
53
|
-
def remove_unused_import(file_path
|
|
57
|
+
def remove_unused_import(file_path, import_name, line_number):
|
|
58
|
+
path = pathlib.Path(file_path)
|
|
54
59
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if original_line.startswith(f'import {import_name}'):
|
|
62
|
-
lines[line_idx] = ''
|
|
63
|
-
|
|
64
|
-
elif original_line.startswith('import ') and f' {import_name}' in original_line:
|
|
65
|
-
parts = original_line.split(' ', 1)[1].split(',')
|
|
66
|
-
new_parts = [p.strip() for p in parts if p.strip() != import_name]
|
|
67
|
-
if new_parts:
|
|
68
|
-
lines[line_idx] = f'import {", ".join(new_parts)}\n'
|
|
69
|
-
else:
|
|
70
|
-
lines[line_idx] = ''
|
|
71
|
-
|
|
72
|
-
elif original_line.startswith('from ') and import_name in original_line:
|
|
73
|
-
if f'import {import_name}' in original_line and ',' not in original_line:
|
|
74
|
-
lines[line_idx] = ''
|
|
75
|
-
else:
|
|
76
|
-
parts = original_line.split('import ', 1)[1].split(',')
|
|
77
|
-
new_parts = [p.strip() for p in parts if p.strip() != import_name]
|
|
78
|
-
if new_parts:
|
|
79
|
-
prefix = original_line.split(' import ')[0]
|
|
80
|
-
lines[line_idx] = f'{prefix} import {", ".join(new_parts)}\n'
|
|
81
|
-
else:
|
|
82
|
-
lines[line_idx] = ''
|
|
83
|
-
|
|
84
|
-
with open(file_path, 'w') as f:
|
|
85
|
-
f.writelines(lines)
|
|
86
|
-
|
|
60
|
+
src = path.read_text(encoding="utf-8")
|
|
61
|
+
new_code, changed = remove_unused_import_cst(src, import_name, line_number)
|
|
62
|
+
if not changed:
|
|
63
|
+
return False
|
|
64
|
+
path.write_text(new_code, encoding="utf-8")
|
|
87
65
|
return True
|
|
88
66
|
except Exception as e:
|
|
89
67
|
logging.error(f"Failed to remove import {import_name} from {file_path}: {e}")
|
|
90
68
|
return False
|
|
91
69
|
|
|
92
|
-
def remove_unused_function(file_path
|
|
70
|
+
def remove_unused_function(file_path, function_name, line_number):
|
|
71
|
+
path = pathlib.Path(file_path)
|
|
93
72
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (node.name in function_name and
|
|
103
|
-
node.lineno == line_number):
|
|
104
|
-
|
|
105
|
-
start_line = node.lineno - 1
|
|
106
|
-
|
|
107
|
-
if node.decorator_list:
|
|
108
|
-
start_line = node.decorator_list[0].lineno - 1
|
|
109
|
-
|
|
110
|
-
end_line = len(lines)
|
|
111
|
-
base_indent = len(lines[start_line]) - len(lines[start_line].lstrip())
|
|
112
|
-
|
|
113
|
-
for i in range(node.end_lineno, len(lines)):
|
|
114
|
-
if lines[i].strip() == '':
|
|
115
|
-
continue
|
|
116
|
-
current_indent = len(lines[i]) - len(lines[i].lstrip())
|
|
117
|
-
if current_indent <= base_indent and lines[i].strip():
|
|
118
|
-
end_line = i
|
|
119
|
-
break
|
|
120
|
-
|
|
121
|
-
while end_line < len(lines) and lines[end_line].strip() == '':
|
|
122
|
-
end_line += 1
|
|
123
|
-
|
|
124
|
-
new_lines = lines[:start_line] + lines[end_line:]
|
|
125
|
-
|
|
126
|
-
with open(file_path, 'w') as f:
|
|
127
|
-
f.write('\n'.join(new_lines) + '\n')
|
|
128
|
-
|
|
129
|
-
return True
|
|
130
|
-
|
|
131
|
-
return False
|
|
132
|
-
except:
|
|
133
|
-
logging.error(f"Failed to remove function {function_name}")
|
|
73
|
+
src = path.read_text(encoding="utf-8")
|
|
74
|
+
new_code, changed = remove_unused_function_cst(src, function_name, line_number)
|
|
75
|
+
if not changed:
|
|
76
|
+
return False
|
|
77
|
+
path.write_text(new_code, encoding="utf-8")
|
|
78
|
+
return True
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logging.error(f"Failed to remove function {function_name} from {file_path}: {e}")
|
|
134
81
|
return False
|
|
135
82
|
|
|
136
83
|
def interactive_selection(logger, unused_functions, unused_imports):
|
|
@@ -142,7 +89,7 @@ def interactive_selection(logger, unused_functions, unused_imports):
|
|
|
142
89
|
selected_imports = []
|
|
143
90
|
|
|
144
91
|
if unused_functions:
|
|
145
|
-
logger.info(f"\n{Colors.CYAN}{Colors.BOLD}Select unused functions to remove:{Colors.RESET}")
|
|
92
|
+
logger.info(f"\n{Colors.CYAN}{Colors.BOLD}Select unused functions to remove (hit spacebar to select):{Colors.RESET}")
|
|
146
93
|
|
|
147
94
|
function_choices = []
|
|
148
95
|
|
|
@@ -162,7 +109,7 @@ def interactive_selection(logger, unused_functions, unused_imports):
|
|
|
162
109
|
selected_functions = answers['functions']
|
|
163
110
|
|
|
164
111
|
if unused_imports:
|
|
165
|
-
logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to remove:{Colors.RESET}")
|
|
112
|
+
logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to remove (hit spacebar to select):{Colors.RESET}")
|
|
166
113
|
|
|
167
114
|
import_choices = []
|
|
168
115
|
|
|
@@ -310,7 +257,7 @@ def main() -> None:
|
|
|
310
257
|
logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
|
|
311
258
|
|
|
312
259
|
try:
|
|
313
|
-
result_json =
|
|
260
|
+
result_json = run_analyze(args.path, conf=args.confidence, exclude_folders=list(final_exclude_folders))
|
|
314
261
|
result = json.loads(result_json)
|
|
315
262
|
|
|
316
263
|
except Exception as e:
|
skylos/codemods.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import libcst as cst
|
|
3
|
+
from libcst.metadata import PositionProvider
|
|
4
|
+
|
|
5
|
+
def _bound_name_for_import_alias(alias: cst.ImportAlias):
|
|
6
|
+
if alias.asname:
|
|
7
|
+
return alias.asname.name.value
|
|
8
|
+
node = alias.name
|
|
9
|
+
while isinstance(node, cst.Attribute):
|
|
10
|
+
node = node.value
|
|
11
|
+
return node.value
|
|
12
|
+
|
|
13
|
+
class _RemoveImportAtLine(cst.CSTTransformer):
|
|
14
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
15
|
+
|
|
16
|
+
def __init__(self, target_name, target_line):
|
|
17
|
+
self.target_name = target_name
|
|
18
|
+
self.target_line = target_line
|
|
19
|
+
self.changed = False
|
|
20
|
+
|
|
21
|
+
def _is_target_line(self, node: cst.CSTNode):
|
|
22
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
23
|
+
return bool(pos and pos.start.line == self.target_line)
|
|
24
|
+
|
|
25
|
+
def _filter_aliases(self, aliases):
|
|
26
|
+
kept = []
|
|
27
|
+
for alias in aliases:
|
|
28
|
+
bound = _bound_name_for_import_alias(alias)
|
|
29
|
+
if bound == self.target_name:
|
|
30
|
+
self.changed = True
|
|
31
|
+
continue
|
|
32
|
+
kept.append(alias)
|
|
33
|
+
return kept
|
|
34
|
+
|
|
35
|
+
def leave_Import(self, orig: cst.Import, updated: cst.Import):
|
|
36
|
+
if not self._is_target_line(orig):
|
|
37
|
+
return updated
|
|
38
|
+
kept = self._filter_aliases(updated.names)
|
|
39
|
+
if not kept:
|
|
40
|
+
return cst.RemoveFromParent()
|
|
41
|
+
return updated.with_changes(names=tuple(kept))
|
|
42
|
+
|
|
43
|
+
def leave_ImportFrom(self, orig: cst.ImportFrom, updated: cst.ImportFrom):
|
|
44
|
+
if not self._is_target_line(orig):
|
|
45
|
+
return updated
|
|
46
|
+
if isinstance(updated.names, cst.ImportStar):
|
|
47
|
+
return updated
|
|
48
|
+
kept = self._filter_aliases(list(updated.names))
|
|
49
|
+
if not kept:
|
|
50
|
+
return cst.RemoveFromParent()
|
|
51
|
+
|
|
52
|
+
return updated.with_changes(names=tuple(kept))
|
|
53
|
+
|
|
54
|
+
class _RemoveFunctionAtLine(cst.CSTTransformer):
|
|
55
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
56
|
+
|
|
57
|
+
def __init__(self, func_name, target_line):
|
|
58
|
+
self.func_name = func_name
|
|
59
|
+
self.target_line = target_line
|
|
60
|
+
self.changed = False
|
|
61
|
+
|
|
62
|
+
def _is_target(self, node: cst.CSTNode):
|
|
63
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
64
|
+
return bool(pos and pos.start.line == self.target_line)
|
|
65
|
+
|
|
66
|
+
def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
|
|
67
|
+
if self._is_target(orig) and (orig.name.value in self.func_name):
|
|
68
|
+
self.changed = True
|
|
69
|
+
return cst.RemoveFromParent()
|
|
70
|
+
return updated
|
|
71
|
+
|
|
72
|
+
def leave_AsyncFunctionDef(self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef):
|
|
73
|
+
if self._is_target(orig) and (orig.name.value in self.func_name):
|
|
74
|
+
self.changed = True
|
|
75
|
+
return cst.RemoveFromParent()
|
|
76
|
+
|
|
77
|
+
return updated
|
|
78
|
+
|
|
79
|
+
def remove_unused_import_cst(code, import_name, line_number):
|
|
80
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
81
|
+
tx = _RemoveImportAtLine(import_name, line_number)
|
|
82
|
+
new_mod = wrapper.visit(tx)
|
|
83
|
+
return new_mod.code, tx.changed
|
|
84
|
+
|
|
85
|
+
def remove_unused_function_cst(code, func_name, line_number):
|
|
86
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
87
|
+
tx = _RemoveFunctionAtLine(func_name, line_number)
|
|
88
|
+
new_mod = wrapper.visit(tx)
|
|
89
|
+
return new_mod.code, tx.changed
|