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.
- {skylos-2.0.0 → skylos-2.1.0}/PKG-INFO +4 -1
- {skylos-2.0.0 → skylos-2.1.0}/README.md +17 -55
- {skylos-2.0.0 → skylos-2.1.0}/pyproject.toml +2 -2
- {skylos-2.0.0 → skylos-2.1.0}/setup.py +5 -3
- {skylos-2.0.0 → skylos-2.1.0}/skylos/__init__.py +1 -1
- {skylos-2.0.0 → skylos-2.1.0}/skylos/analyzer.py +48 -29
- {skylos-2.0.0 → skylos-2.1.0}/skylos/cli.py +26 -79
- skylos-2.1.0/skylos/codemods.py +89 -0
- skylos-2.1.0/skylos/framework_aware.py +358 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos/visitor.py +187 -42
- {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/PKG-INFO +4 -1
- {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/SOURCES.txt +2 -0
- skylos-2.1.0/skylos.egg-info/requires.txt +4 -0
- skylos-2.1.0/test/test_codemods.py +153 -0
- skylos-2.1.0/test/test_framework_aware.py +306 -0
- skylos-2.0.0/skylos/framework_aware.py +0 -164
- skylos-2.0.0/skylos.egg-info/requires.txt +0 -1
- skylos-2.0.0/test/test_framework_aware.py +0 -372
- {skylos-2.0.0 → skylos-2.1.0}/setup.cfg +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos/constants.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos/server.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos/test_aware.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/dependency_links.txt +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/entry_points.txt +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/skylos.egg-info/top_level.txt +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/__init__.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/compare_tools.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/conftest.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/diagnostics.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/__init__.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/app.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/__init__.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/commands.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/models.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/routes.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/sample_repo/sample_repo/utils.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_analyzer.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_changes_analyzer.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_cli.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_constants.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_integration.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_skylos.py +0 -0
- {skylos-2.0.0 → skylos-2.1.0}/test/test_test_aware.py +0 -0
- {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.
|
|
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
|
|
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
|
-
- [
|
|
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
|
-
##
|
|
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
|
|
232
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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"]:
|
|
@@ -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:
|
|
@@ -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
|