skylos 2.0.0__tar.gz → 2.1.0__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.

Potentially problematic release.


This version of skylos might be problematic. Click here for more details.

Files changed (44) hide show
  1. {skylos-2.0.0 → skylos-2.1.0}/PKG-INFO +4 -1
  2. {skylos-2.0.0 → skylos-2.1.0}/README.md +17 -55
  3. {skylos-2.0.0 → skylos-2.1.0}/pyproject.toml +2 -2
  4. {skylos-2.0.0 → skylos-2.1.0}/setup.py +5 -3
  5. {skylos-2.0.0 → skylos-2.1.0}/skylos/__init__.py +1 -1
  6. {skylos-2.0.0 → skylos-2.1.0}/skylos/analyzer.py +48 -29
  7. {skylos-2.0.0 → skylos-2.1.0}/skylos/cli.py +26 -79
  8. skylos-2.1.0/skylos/codemods.py +89 -0
  9. skylos-2.1.0/skylos/framework_aware.py +358 -0
  10. {skylos-2.0.0 → skylos-2.1.0}/skylos/visitor.py +187 -42
  11. {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/PKG-INFO +4 -1
  12. {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/SOURCES.txt +2 -0
  13. skylos-2.1.0/skylos.egg-info/requires.txt +4 -0
  14. skylos-2.1.0/test/test_codemods.py +153 -0
  15. skylos-2.1.0/test/test_framework_aware.py +306 -0
  16. skylos-2.0.0/skylos/framework_aware.py +0 -164
  17. skylos-2.0.0/skylos.egg-info/requires.txt +0 -1
  18. skylos-2.0.0/test/test_framework_aware.py +0 -372
  19. {skylos-2.0.0 → skylos-2.1.0}/setup.cfg +0 -0
  20. {skylos-2.0.0 → skylos-2.1.0}/skylos/constants.py +0 -0
  21. {skylos-2.0.0 → skylos-2.1.0}/skylos/server.py +0 -0
  22. {skylos-2.0.0 → skylos-2.1.0}/skylos/test_aware.py +0 -0
  23. {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/dependency_links.txt +0 -0
  24. {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/entry_points.txt +0 -0
  25. {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/top_level.txt +0 -0
  26. {skylos-2.0.0 → skylos-2.1.0}/test/__init__.py +0 -0
  27. {skylos-2.0.0 → skylos-2.1.0}/test/compare_tools.py +0 -0
  28. {skylos-2.0.0 → skylos-2.1.0}/test/conftest.py +0 -0
  29. {skylos-2.0.0 → skylos-2.1.0}/test/diagnostics.py +0 -0
  30. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/__init__.py +0 -0
  31. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/app.py +0 -0
  32. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/__init__.py +0 -0
  33. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/commands.py +0 -0
  34. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/models.py +0 -0
  35. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/routes.py +0 -0
  36. {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/utils.py +0 -0
  37. {skylos-2.0.0 → skylos-2.1.0}/test/test_analyzer.py +0 -0
  38. {skylos-2.0.0 → skylos-2.1.0}/test/test_changes_analyzer.py +0 -0
  39. {skylos-2.0.0 → skylos-2.1.0}/test/test_cli.py +0 -0
  40. {skylos-2.0.0 → skylos-2.1.0}/test/test_constants.py +0 -0
  41. {skylos-2.0.0 → skylos-2.1.0}/test/test_integration.py +0 -0
  42. {skylos-2.0.0 → skylos-2.1.0}/test/test_skylos.py +0 -0
  43. {skylos-2.0.0 → skylos-2.1.0}/test/test_test_aware.py +0 -0
  44. {skylos-2.0.0 → skylos-2.1.0}/test/test_visitor.py +0 -0
@@ -1,8 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skylos
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: A static analysis tool for Python codebases
5
5
  Author-email: oha <aaronoh2015@gmail.com>
6
6
  Requires-Python: >=3.9
7
7
  Requires-Dist: inquirer>=3.0.0
8
+ Requires-Dist: flask>=2.1.0
9
+ Requires-Dist: flask-cors>=3.0.0
10
+ Requires-Dist: libcst>=1.8.2
8
11
  Dynamic: requires-python
@@ -9,7 +9,7 @@
9
9
  <img src="assets/SKYLOS.png" alt="Skylos Logo" width="200">
10
10
  </div>
11
11
 
12
- > A static analysis tool for Python codebases written in Python (formerly was written in Rust but we ditched that) that detects unreachable functions and unused imports, aka dead code. Faster and better results than many alternatives like Flake8 and Pylint, and finding more dead code than Vulture in our tests with comparable speed.
12
+ > A static analysis tool for Python codebases written in Python that detects unreachable functions and unused imports, aka dead code. Faster and better results than many alternatives like Flake8 and Pylint, and finding more dead code than Vulture in our tests with comparable speed.
13
13
 
14
14
  <div align="center">
15
15
  <img src="assets/FE_SS.png" alt="FE" width="800">
@@ -23,7 +23,7 @@
23
23
  - [Installation](#installation)
24
24
  - [Quick Start](#quick-start)
25
25
  - [Web Interface](#web-interface)
26
- - [Understanding Confidence Levels](#understanding-confidence-levels)
26
+ - [Design](#design)
27
27
  - [Test File Detection](#test-file-detection)
28
28
  - [Folder Management](#folder-management)
29
29
  - [Ignoring Pragmas](#ignoring-pragmas)
@@ -42,6 +42,7 @@
42
42
 
43
43
  ## Features
44
44
 
45
+ * **CST-safe removals:** Uses LibCST to remove selected imports or functions (handles multiline imports, aliases, decorators, async etc..)
45
46
  * **Framework-Aware Detection**: Attempt at handling Flask, Django, FastAPI routes and decorators
46
47
  * **Test File Exclusion**: Auto excludes test files (you can include it back if you want)
47
48
  * **Interactive Cleanup**: Select specific items to remove from CLI
@@ -82,18 +83,15 @@ pip install skylos
82
83
  ### From Source
83
84
 
84
85
  ```bash
85
- # Clone the repository
86
86
  git clone https://github.com/duriantaco/skylos.git
87
87
  cd skylos
88
88
 
89
- ## Install your dependencies
90
89
  pip install .
91
90
  ```
92
91
 
93
92
  ## Quick Start
94
93
 
95
94
  ```bash
96
- # Analyze a project
97
95
  skylos /path/to/your/project
98
96
 
99
97
  # To launch the front end
@@ -105,7 +103,6 @@ skylos --interactive /path/to/your/project
105
103
  # Dry run - see what would be removed
106
104
  skylos --interactive --dry-run /path/to/your/project
107
105
 
108
- # Output to JSON
109
106
  skylos --json /path/to/your/project
110
107
 
111
108
  # With confidence
@@ -117,13 +114,22 @@ skylos path/to/your/file --confidence 20 ## or whatever value u wanna set
117
114
  Skylos includes a modern web dashboard for interactive analysis:
118
115
 
119
116
  ```bash
120
- # Start web interface
121
117
  skylos run
122
118
 
123
119
  # Opens browser at http://localhost:5090
124
120
  ```
125
121
 
126
- ## Understanding Confidence Levels
122
+ ## Design
123
+
124
+ ### Summary
125
+
126
+ Framework endpoints are often invoked externally (eg, via HTTP, signals), so we use framework aware signals + confidence scores to try avoid false positives while still catching dead codes
127
+
128
+ Name resolution handles aliases and modules, but when things get ambiguous, we rather miss some dead code than accidentally mark live code as dead lmao
129
+
130
+ Tests are excluded by default because their call patterns are noisy. You can opt in when you really need to audit it
131
+
132
+ ### Understanding Confidence Levels
127
133
 
128
134
  Skylos uses a confidence-based system to try to handle Python's dynamic nature and web frameworks.
129
135
 
@@ -204,7 +210,6 @@ By default, Skylos excludes common folders: `__pycache__`, `.git`, `.pytest_cach
204
210
 
205
211
  ### Folder Options
206
212
  ```bash
207
- # List default excluded folders
208
213
  skylos --list-default-excludes
209
214
 
210
215
  # Exclude single folder (The example here will be venv)
@@ -228,8 +233,8 @@ Arguments:
228
233
  PATH Path to the Python project to analyze
229
234
 
230
235
  Options:
231
- -h, --help Show this help message and exit
232
- -j, --json Output raw JSON instead of formatted text
236
+ -h, --help Show this help message and exit
237
+ --json Output raw JSON instead of formatted text
233
238
  -o, --output FILE Write output to file instead of stdout
234
239
  -v, --verbose Enable verbose output
235
240
  -i, --interactive Interactively select items to remove
@@ -241,48 +246,11 @@ Options:
241
246
  -c, --confidence LEVEL Confidence threshold (0-100). Lower values will show more items.
242
247
  ```
243
248
 
244
- ## Example Output
245
-
246
- ```
247
- Python Static Analysis Results
248
- ===================================
249
-
250
- Summary:
251
- • Unreachable functions: 48
252
- • Unused imports: 8
253
-
254
- Unreachable Functions
255
- ========================
256
-
257
- 1. module_13.test_function
258
- └─ /Users/oha/project/module_13.py:5
259
- 2. module_13.unused_function
260
- └─ /Users/oha/project/module_13.py:13
261
- ...
262
-
263
-
264
- Unused Imports
265
- =================
266
-
267
- 1. os
268
- └─ /Users/oha/project/module_13.py:1
269
- 2. json
270
- └─ /Users/oha/project/module_13.py:3
271
- ...
272
- ```
273
-
274
- Next steps:
275
-
276
- • Use `--interactive` to select specific items to remove
277
-
278
- • Use `--dry-run` to preview changes before applying them
279
-
280
-
281
249
  ## Interactive Mode
282
250
 
283
251
  The interactive mode lets you select specific functions and imports to remove:
284
252
 
285
- 1. **Select items**: Use arrow keys and space to select/deselect
253
+ 1. **Select items**: Use arrow keys and `spacebar` to select/unselect
286
254
  2. **Confirm changes**: Review selected items before applying
287
255
  3. **Auto-cleanup**: Files are automatically updated
288
256
 
@@ -297,11 +265,9 @@ The interactive mode lets you select specific functions and imports to remove:
297
265
  ### Setup
298
266
 
299
267
  ```bash
300
- # Clone the repository
301
268
  git clone https://github.com/duriantaco/skylos.git
302
269
  cd skylos
303
270
 
304
- # Create a virtual environment
305
271
  python -m venv venv
306
272
  source venv/bin/activate # On Windows: venv\Scripts\activate
307
273
  ```
@@ -309,7 +275,6 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
309
275
  ### Running Tests
310
276
 
311
277
  ```bash
312
- # Run Python tests
313
278
  python -m pytest tests/
314
279
  ```
315
280
 
@@ -343,8 +308,6 @@ A: Start with 60 (default) for safe cleanup. Use 30 for framework applications.
343
308
  - **Test data**: Limited scenarios, your mileage may vary
344
309
  - **False positives**: Always manually review before deleting code
345
310
 
346
- This is a STATIC code analyzer. If we can detect 100% of all dead code in any structure and framework, we wouldn't be sitting here. But we definitely tried our best.
347
-
348
311
  ## Troubleshooting
349
312
 
350
313
  ### Common Issues
@@ -378,7 +341,6 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
378
341
  - [ ] Configuration file support
379
342
  - [ ] Git hooks integration
380
343
  - [ ] CI/CD integration examples
381
- ~~- [] Support for other languages (unless I have contributors, this ain't possible)~~
382
344
  - [ ] Further optimization
383
345
 
384
346
  ## License
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skylos"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  requires-python = ">=3.9"
9
9
  description = "A static analysis tool for Python codebases"
10
10
  authors = [{name = "oha", email = "aaronoh2015@gmail.com"}]
11
- dependencies = ["inquirer>=3.0.0"]
11
+ dependencies = ["inquirer>=3.0.0", "flask>=2.1.0", "flask-cors>=3.0.0", "libcst>=1.8.2"]
12
12
 
13
13
  [project.scripts]
14
14
  skylos = "skylos.cli:main"
@@ -2,13 +2,15 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="skylos",
5
- version="2.0.0",
5
+ version="2.1.0",
6
6
  packages=find_packages(),
7
7
  python_requires=">=3.9",
8
8
  install_requires=[
9
9
  "inquirer>=3.0.0",
10
- "flask>=2.0.0",
11
- "flask-cors>=3.0.0"],
10
+ "flask>=2.1.0",
11
+ "flask-cors>=3.0.0",
12
+ "libcst>=1.8.2"],
13
+
12
14
  classifiers=[
13
15
  "Development Status :: 4 - Beta",
14
16
  "Intended Audience :: Developers",
@@ -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"
@@ -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"]:
@@ -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:
@@ -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