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 CHANGED
@@ -1,6 +1,6 @@
1
1
  from skylos.analyzer import analyze
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.1.0"
4
4
 
5
5
  def debug_test():
6
6
  return "debug-ok"
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 ( DEFAULT_EXCLUDE_FOLDERS, PENALTIES, AUTO_CALLED )
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
- matches = simple_name_lookup.get(simple, [])
137
- for definition in matches:
138
- definition.references += 1
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 -= PENALTIES["underscored_var"]
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
- thr = max(0, thr)
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 if exclude_folders else [],
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 = sum(len(items) for items in data.values())
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: str, import_name: str, line_number: int) -> bool:
57
+ def remove_unused_import(file_path, import_name, line_number):
58
+ path = pathlib.Path(file_path)
54
59
  try:
55
- with open(file_path, 'r') as f:
56
- lines = f.readlines()
57
-
58
- line_idx = line_number - 1
59
- original_line = lines[line_idx].strip()
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: str, function_name: str, line_number: int) -> bool:
70
+ def remove_unused_function(file_path, function_name, line_number):
71
+ path = pathlib.Path(file_path)
93
72
  try:
94
- with open(file_path, 'r') as f:
95
- content = f.read()
96
-
97
- tree = ast.parse(content)
98
-
99
- lines = content.splitlines()
100
- for node in ast.walk(tree):
101
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
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 = skylos.analyze(args.path, conf=args.confidence, exclude_folders=list(final_exclude_folders))
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