skylos 1.0.7__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 ADDED
@@ -0,0 +1,8 @@
1
+ from skylos.analyzer import analyze
2
+
3
+ __version__ = "1.0.7"
4
+
5
+ def debug_test():
6
+ return "debug-ok"
7
+
8
+ __all__ = ["analyze", "debug_test", "__version__"]
skylos/analyzer.py ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ import ast,sys,json,logging,re
3
+ from pathlib import Path
4
+ from collections import defaultdict
5
+ from skylos.visitor import Visitor
6
+
7
+ logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(message)s')
8
+ logger=logging.getLogger('Skylos')
9
+
10
+ AUTO_CALLED={"__init__","__enter__","__exit__"}
11
+ TEST_BASE_CLASSES = {"TestCase", "AsyncioTestCase", "unittest.TestCase", "unittest.AsyncioTestCase"}
12
+ TEST_METHOD_PATTERN = re.compile(r"^test_\w+$")
13
+ MAGIC_METHODS={f"__{n}__"for n in["init","new","call","getattr","getattribute","enter","exit","str","repr","hash","eq","ne","lt","gt","le","ge","iter","next","contains","len","getitem","setitem","delitem","iadd","isub","imul","itruediv","ifloordiv","imod","ipow","ilshift","irshift","iand","ixor","ior","round","format","dir","abs","complex","int","float","bool","bytes","reduce","await","aiter","anext","add","sub","mul","truediv","floordiv","mod","divmod","pow","lshift","rshift","and","or","xor","radd","rsub","rmul","rtruediv","rfloordiv","rmod","rdivmod","rpow","rlshift","rrshift","rand","ror","rxor"]}
14
+
15
+ class Skylos:
16
+ def __init__(self):
17
+ self.defs={}
18
+ self.refs=[]
19
+ self.dynamic=set()
20
+ self.exports=defaultdict(set)
21
+
22
+ def _module(self,root,f):
23
+ p=list(f.relative_to(root).parts)
24
+ if p[-1].endswith(".py"):p[-1]=p[-1][:-3]
25
+ if p[-1]=="__init__":p.pop()
26
+ return".".join(p)
27
+
28
+ def _mark_exports(self):
29
+
30
+ for name, d in self.defs.items():
31
+ if d.in_init and not d.simple_name.startswith('_'):
32
+ d.is_exported = True
33
+
34
+ for mod, export_names in self.exports.items():
35
+ for name in export_names:
36
+ for def_name, def_obj in self.defs.items():
37
+ if (def_name.startswith(f"{mod}.") and
38
+ def_obj.simple_name == name and
39
+ def_obj.type != "import"):
40
+ def_obj.is_exported = True
41
+
42
+ def _mark_refs(self):
43
+ import_to_original = {}
44
+ for name, def_obj in self.defs.items():
45
+ if def_obj.type == "import":
46
+ import_name = name.split('.')[-1]
47
+
48
+ for def_name, orig_def in self.defs.items():
49
+ if (orig_def.type != "import" and
50
+ orig_def.simple_name == import_name and
51
+ def_name != name):
52
+ import_to_original[name] = def_name
53
+ break
54
+
55
+ simple_name_lookup = defaultdict(list)
56
+ for d in self.defs.values():
57
+ simple_name_lookup[d.simple_name].append(d)
58
+
59
+ for ref, file in self.refs:
60
+ if ref in self.defs:
61
+ self.defs[ref].references += 1
62
+
63
+ if ref in import_to_original:
64
+ original = import_to_original[ref]
65
+ self.defs[original].references += 1
66
+ continue
67
+
68
+ simple = ref.split('.')[-1]
69
+ matches = simple_name_lookup.get(simple, [])
70
+ for d in matches:
71
+ d.references += 1
72
+
73
+ def _get_base_classes(self, class_name):
74
+ """Get base classes for a given class name"""
75
+ if class_name not in self.defs:
76
+ return []
77
+
78
+ class_def = self.defs[class_name]
79
+
80
+ if hasattr(class_def, 'base_classes'):
81
+ return class_def.base_classes
82
+
83
+ return []
84
+
85
+ def _apply_heuristics(self):
86
+
87
+ class_methods=defaultdict(list)
88
+ for d in self.defs.values():
89
+ if d.type in("method","function") and"." in d.name:
90
+ cls=d.name.rsplit(".",1)[0]
91
+ if cls in self.defs and self.defs[cls].type=="class":
92
+ class_methods[cls].append(d)
93
+
94
+ for cls,methods in class_methods.items():
95
+ if self.defs[cls].references>0:
96
+ for m in methods:
97
+ if m.simple_name in AUTO_CALLED:m.references+=1
98
+
99
+ for d in self.defs.values():
100
+ if d.simple_name in MAGIC_METHODS or d.simple_name.startswith("__")and d.simple_name.endswith("__"):d.confidence=0
101
+ if not d.simple_name.startswith("_")and d.type in("function","method","class"):d.confidence=min(d.confidence,90)
102
+ if d.in_init and d.type in("function","class"):d.confidence=min(d.confidence,85)
103
+ if d.name.split(".")[0] in self.dynamic:d.confidence=min(d.confidence,50)
104
+
105
+ for d in self.defs.values():
106
+ if d.type == "method" and TEST_METHOD_PATTERN.match(d.simple_name):
107
+ # check if its in a class that inherits from a test base class
108
+ class_name = d.name.rsplit(".", 1)[0]
109
+ class_simple_name = class_name.split(".")[-1]
110
+ # class name suggests it's a test class, ignore test methods
111
+ if "Test" in class_simple_name or class_simple_name.endswith("TestCase"):
112
+ d.confidence = 0
113
+
114
+ def analyze(self, path, thr=60):
115
+ p = Path(path).resolve()
116
+ files = [p] if p.is_file() else list(p.glob("**/*.py"))
117
+ root = p.parent if p.is_file() else p
118
+
119
+ modmap = {}
120
+ for f in files:
121
+ modmap[f] = self._module(root, f)
122
+
123
+ for file in files:
124
+ mod = modmap[file]
125
+ defs, refs, dyn, exports = proc_file(file, mod)
126
+
127
+ for d in defs:
128
+ self.defs[d.name] = d
129
+ self.refs.extend(refs)
130
+ self.dynamic.update(dyn)
131
+ self.exports[mod].update(exports)
132
+
133
+ self._mark_refs()
134
+ self._apply_heuristics()
135
+ self._mark_exports()
136
+
137
+ # for name, d in self.defs.items():
138
+ # print(f" {d.type} '{name}': {d.references} refs, exported: {d.is_exported}, confidence: {d.confidence}")
139
+
140
+ thr = max(0, thr)
141
+
142
+ unused = []
143
+ for d in self.defs.values():
144
+ if d.references == 0 and not d.is_exported and d.confidence >= thr:
145
+ unused.append(d.to_dict())
146
+
147
+ result = {"unused_functions": [], "unused_imports": [], "unused_classes": []}
148
+ for u in unused:
149
+ if u["type"] in ("function", "method"):
150
+ result["unused_functions"].append(u)
151
+ elif u["type"] == "import":
152
+ result["unused_imports"].append(u)
153
+ elif u["type"] == "class":
154
+ result["unused_classes"].append(u)
155
+
156
+ return json.dumps(result, indent=2)
157
+
158
+ def proc_file(file_or_args, mod=None):
159
+ if mod is None and isinstance(file_or_args, tuple):
160
+ file, mod = file_or_args
161
+ else:
162
+ file = file_or_args
163
+
164
+ try:
165
+ tree = ast.parse(Path(file).read_text(encoding="utf-8"))
166
+ v = Visitor(mod, file)
167
+ v.visit(tree)
168
+ return v.defs, v.refs, v.dyn, v.exports
169
+ except Exception as e:
170
+ logger.error(f"{file}: {e}")
171
+ return [], [], set(), set()
172
+
173
+ def analyze(path,conf=60):return Skylos().analyze(path,conf)
174
+
175
+ if __name__=="__main__":
176
+ if len(sys.argv)>1:
177
+ p=sys.argv[1];c=int(sys.argv[2])if len(sys.argv)>2 else 60
178
+ print(analyze(p,c))
179
+ else:
180
+ print("Usage: python Skylos.py <path> [confidence_threshold]")
skylos/cli.py ADDED
@@ -0,0 +1,332 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ import logging
5
+ import ast
6
+ import skylos
7
+
8
+ try:
9
+ import inquirer
10
+ INTERACTIVE_AVAILABLE = True
11
+ except ImportError:
12
+ INTERACTIVE_AVAILABLE = False
13
+
14
+ class Colors:
15
+ RED = '\033[91m'
16
+ GREEN = '\033[92m'
17
+ YELLOW = '\033[93m'
18
+ BLUE = '\033[94m'
19
+ MAGENTA = '\033[95m'
20
+ CYAN = '\033[96m'
21
+ WHITE = '\033[97m'
22
+ BOLD = '\033[1m'
23
+ UNDERLINE = '\033[4m'
24
+ RESET = '\033[0m'
25
+ GRAY = '\033[90m'
26
+
27
+ class ColoredFormatter(logging.Formatter):
28
+ def format(self, record):
29
+ return super().format(record)
30
+
31
+ def setup_logger(output_file=None):
32
+ logger = logging.getLogger('skylos')
33
+ logger.setLevel(logging.INFO)
34
+
35
+ logger.handlers.clear()
36
+
37
+ formatter = ColoredFormatter('%(message)s')
38
+
39
+ console_handler = logging.StreamHandler(sys.stdout)
40
+ console_handler.setFormatter(formatter)
41
+ logger.addHandler(console_handler)
42
+
43
+ if output_file:
44
+ file_handler = logging.FileHandler(output_file)
45
+ file_handler.setFormatter(formatter)
46
+ logger.addHandler(file_handler)
47
+
48
+ return logger
49
+
50
+ def remove_unused_import(file_path: str, import_name: str, line_number: int) -> bool:
51
+ try:
52
+ with open(file_path, 'r') as f:
53
+ lines = f.readlines()
54
+
55
+ line_idx = line_number - 1
56
+ original_line = lines[line_idx].strip()
57
+
58
+ if original_line.startswith(f'import {import_name}'):
59
+ lines[line_idx] = ''
60
+ elif original_line.startswith('import ') and f' {import_name}' in original_line:
61
+ parts = original_line.split(' ', 1)[1].split(',')
62
+ new_parts = [p.strip() for p in parts if p.strip() != import_name]
63
+ if new_parts:
64
+ lines[line_idx] = f'import {", ".join(new_parts)}\n'
65
+ else:
66
+ lines[line_idx] = ''
67
+ elif original_line.startswith('from ') and import_name in original_line:
68
+ if f'import {import_name}' in original_line and ',' not in original_line:
69
+ lines[line_idx] = ''
70
+ else:
71
+ parts = original_line.split('import ', 1)[1].split(',')
72
+ new_parts = [p.strip() for p in parts if p.strip() != import_name]
73
+ if new_parts:
74
+ prefix = original_line.split(' import ')[0]
75
+ lines[line_idx] = f'{prefix} import {", ".join(new_parts)}\n'
76
+ else:
77
+ lines[line_idx] = ''
78
+
79
+ with open(file_path, 'w') as f:
80
+ f.writelines(lines)
81
+
82
+ return True
83
+ except Exception as e:
84
+ logging.error(f"Failed to remove import {import_name} from {file_path}: {e}")
85
+ return False
86
+
87
+ def remove_unused_function(file_path: str, function_name: str, line_number: int) -> bool:
88
+ try:
89
+ with open(file_path, 'r') as f:
90
+ content = f.read()
91
+
92
+ tree = ast.parse(content)
93
+
94
+ lines = content.splitlines()
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
97
+ if (node.name in function_name and
98
+ node.lineno == line_number):
99
+
100
+ start_line = node.lineno - 1
101
+
102
+ if node.decorator_list:
103
+ start_line = node.decorator_list[0].lineno - 1
104
+
105
+ end_line = len(lines)
106
+ base_indent = len(lines[start_line]) - len(lines[start_line].lstrip())
107
+
108
+ for i in range(node.end_lineno, len(lines)):
109
+ if lines[i].strip() == '':
110
+ continue
111
+ current_indent = len(lines[i]) - len(lines[i].lstrip())
112
+ if current_indent <= base_indent and lines[i].strip():
113
+ end_line = i
114
+ break
115
+
116
+ while end_line < len(lines) and lines[end_line].strip() == '':
117
+ end_line += 1
118
+
119
+ new_lines = lines[:start_line] + lines[end_line:]
120
+
121
+ with open(file_path, 'w') as f:
122
+ f.write('\n'.join(new_lines) + '\n')
123
+
124
+ return True
125
+
126
+ return False
127
+ except Exception as e:
128
+ logging.error(f"Failed to remove function {function_name} from {file_path}: {e}")
129
+ return False
130
+
131
+ def interactive_selection(logger, unused_functions, unused_imports):
132
+ if not INTERACTIVE_AVAILABLE:
133
+ logger.error("Interactive mode requires 'inquirer' package. Install with: pip install inquirer")
134
+ return [], []
135
+
136
+ selected_functions = []
137
+ selected_imports = []
138
+
139
+ if unused_functions:
140
+ logger.info(f"\n{Colors.CYAN}{Colors.BOLD}Select unused functions to remove:{Colors.RESET}")
141
+
142
+ function_choices = []
143
+ for item in unused_functions:
144
+ choice_text = f"{item['name']} ({item['file']}:{item['line']})"
145
+ function_choices.append((choice_text, item))
146
+
147
+ questions = [
148
+ inquirer.Checkbox('functions',
149
+ message="Select functions to remove",
150
+ choices=function_choices,
151
+ )
152
+ ]
153
+
154
+ answers = inquirer.prompt(questions)
155
+ if answers:
156
+ selected_functions = answers['functions']
157
+
158
+ if unused_imports:
159
+ logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to remove:{Colors.RESET}")
160
+
161
+ import_choices = []
162
+ for item in unused_imports:
163
+ choice_text = f"{item['name']} ({item['file']}:{item['line']})"
164
+ import_choices.append((choice_text, item))
165
+
166
+ questions = [
167
+ inquirer.Checkbox('imports',
168
+ message="Select imports to remove",
169
+ choices=import_choices,
170
+ )
171
+ ]
172
+
173
+ answers = inquirer.prompt(questions)
174
+ if answers:
175
+ selected_imports = answers['imports']
176
+
177
+ return selected_functions, selected_imports
178
+
179
+ def main() -> None:
180
+ parser = argparse.ArgumentParser(
181
+ description="Detect unreachable functions and unused imports in a Python project"
182
+ )
183
+ parser.add_argument("path", help="Path to the Python project to analyze")
184
+ parser.add_argument(
185
+ "--json",
186
+ action="store_true",
187
+ help="Output raw JSON instead of formatted text",
188
+ )
189
+ parser.add_argument(
190
+ "--output",
191
+ "-o",
192
+ type=str,
193
+ help="Write output to file instead of stdout",
194
+ )
195
+ parser.add_argument(
196
+ "--verbose", "-v",
197
+ action="store_true",
198
+ help="Enable verbose output"
199
+ )
200
+ parser.add_argument(
201
+ "--interactive", "-i",
202
+ action="store_true",
203
+ help="Interactively select items to remove (requires inquirer)"
204
+ )
205
+ parser.add_argument(
206
+ "--dry-run",
207
+ action="store_true",
208
+ help="Show what would be removed without actually modifying files"
209
+ )
210
+
211
+ args = parser.parse_args()
212
+ logger = setup_logger(args.output)
213
+
214
+ if args.verbose:
215
+ logger.setLevel(logging.DEBUG)
216
+ logger.debug(f"Analyzing path: {args.path}")
217
+
218
+ try:
219
+ result_json = skylos.analyze(args.path)
220
+ result = json.loads(result_json)
221
+ except Exception as e:
222
+ logger.error(f"Error during analysis: {e}")
223
+ sys.exit(1)
224
+
225
+ if args.json:
226
+ logger.info(result_json)
227
+ else:
228
+ unused_functions = result.get("unused_functions", [])
229
+ unused_imports = result.get("unused_imports", [])
230
+
231
+ logger.info(f"\n{Colors.CYAN}{Colors.BOLD}šŸ” Python Static Analysis Results{Colors.RESET}")
232
+ logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
233
+
234
+ logger.info(f"\n{Colors.BOLD}Summary:{Colors.RESET}")
235
+ logger.info(f" • Unreachable functions: {Colors.YELLOW}{len(unused_functions)}{Colors.RESET}")
236
+ logger.info(f" • Unused imports: {Colors.YELLOW}{len(unused_imports)}{Colors.RESET}")
237
+
238
+ if args.interactive and (unused_functions or unused_imports):
239
+ logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
240
+ selected_functions, selected_imports = interactive_selection(logger, unused_functions, unused_imports)
241
+
242
+ if selected_functions or selected_imports:
243
+ logger.info(f"\n{Colors.BOLD}Selected items to remove:{Colors.RESET}")
244
+
245
+ if selected_functions:
246
+ logger.info(f" Functions: {len(selected_functions)}")
247
+ for func in selected_functions:
248
+ logger.info(f" - {func['name']} ({func['file']}:{func['line']})")
249
+
250
+ if selected_imports:
251
+ logger.info(f" Imports: {len(selected_imports)}")
252
+ for imp in selected_imports:
253
+ logger.info(f" - {imp['name']} ({imp['file']}:{imp['line']})")
254
+
255
+ if not args.dry_run:
256
+ questions = [
257
+ inquirer.Confirm('confirm',
258
+ message="Are you sure you want to remove these items?",
259
+ default=False)
260
+ ]
261
+ answers = inquirer.prompt(questions)
262
+
263
+ if answers and answers['confirm']:
264
+ logger.info(f"\n{Colors.YELLOW}Removing selected items...{Colors.RESET}")
265
+
266
+ for func in selected_functions:
267
+ success = remove_unused_function(func['file'], func['name'], func['line'])
268
+ if success:
269
+ logger.info(f" {Colors.GREEN}āœ“{Colors.RESET} Removed function: {func['name']}")
270
+ else:
271
+ logger.error(f" {Colors.RED}āœ—{Colors.RESET} Failed to remove: {func['name']}")
272
+
273
+ for imp in selected_imports:
274
+ success = remove_unused_import(imp['file'], imp['name'], imp['line'])
275
+ if success:
276
+ logger.info(f" {Colors.GREEN}āœ“{Colors.RESET} Removed import: {imp['name']}")
277
+ else:
278
+ logger.error(f" {Colors.RED}āœ—{Colors.RESET} Failed to remove: {imp['name']}")
279
+
280
+ logger.info(f"\n{Colors.GREEN}Cleanup complete!{Colors.RESET}")
281
+ else:
282
+ logger.info(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
283
+ else:
284
+ logger.info(f"\n{Colors.YELLOW}Dry run - no files were modified.{Colors.RESET}")
285
+ else:
286
+ logger.info(f"\n{Colors.BLUE}No items selected.{Colors.RESET}")
287
+
288
+ else:
289
+ if unused_functions:
290
+ logger.info(f"\n{Colors.RED}{Colors.BOLD}šŸ“¦ Unreachable Functions{Colors.RESET}")
291
+ logger.info(f"{Colors.RED}{'=' * 23}{Colors.RESET}")
292
+ for i, item in enumerate(unused_functions, 1):
293
+ logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.RED}{item['name']}{Colors.RESET}")
294
+ logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
295
+ else:
296
+ logger.info(f"\n{Colors.GREEN}āœ“ All functions are reachable!{Colors.RESET}")
297
+
298
+ if unused_imports:
299
+ logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}šŸ“„ Unused Imports{Colors.RESET}")
300
+ logger.info(f"{Colors.MAGENTA}{'=' * 16}{Colors.RESET}")
301
+ for i, item in enumerate(unused_imports, 1):
302
+ logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.MAGENTA}{item['name']}{Colors.RESET}")
303
+ logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
304
+ else:
305
+ logger.info(f"\n{Colors.GREEN}āœ“ All imports are being used!{Colors.RESET}")
306
+
307
+ logger.info(f"\n{Colors.GRAY}{'─' * 50}{Colors.RESET}")
308
+ logger.info(f"{Colors.GRAY}Analysis complete.{Colors.RESET}")
309
+
310
+ logger.info(f"\n{Colors.GRAY}{'─' * 50}{Colors.RESET}")
311
+ logger.info(f"{Colors.GRAY}Analysis complete.{Colors.RESET}")
312
+
313
+ dead_code_count = len(unused_functions) + len(unused_imports)
314
+
315
+ if dead_code_count == 0:
316
+ logger.info(f"\n✨ Your code is 100% dead code free! Add this badge to your README:")
317
+ logger.info("```markdown")
318
+ logger.info("![Dead Code Free](https://img.shields.io/badge/Dead_Code-Free-brightgreen?logo=moleculer&logoColor=white)")
319
+ logger.info("```")
320
+ else:
321
+ logger.info(f"Found {dead_code_count} dead code items. Add this badge to your README:")
322
+ logger.info("```markdown")
323
+ logger.info(f"![Dead Code: {dead_code_count}](https://img.shields.io/badge/Dead_Code-{dead_code_count}_detected-orange?logo=codacy&logoColor=red)")
324
+ logger.info("```")
325
+
326
+ if unused_functions or unused_imports:
327
+ logger.info(f"\n{Colors.BOLD}Next steps:{Colors.RESET}")
328
+ logger.info(f" • Use --interactive to select specific items to remove")
329
+ logger.info(f" • Use --dry-run to preview changes before applying them")
330
+
331
+ if __name__ == "__main__":
332
+ main()