skylos 1.0.9__tar.gz → 1.2.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-1.0.9 → skylos-1.2.0}/PKG-INFO +1 -1
- {skylos-1.0.9 → skylos-1.2.0}/README.md +149 -26
- {skylos-1.0.9 → skylos-1.2.0}/pyproject.toml +1 -1
- {skylos-1.0.9 → skylos-1.2.0}/setup.py +1 -1
- {skylos-1.0.9 → skylos-1.2.0}/skylos/__init__.py +1 -1
- skylos-1.2.0/skylos/analyzer.py +374 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos/cli.py +105 -5
- skylos-1.2.0/skylos/constants.py +42 -0
- skylos-1.2.0/skylos/framework_aware.py +158 -0
- skylos-1.2.0/skylos/test_aware.py +66 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos/visitor.py +104 -21
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/PKG-INFO +1 -1
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/SOURCES.txt +11 -0
- skylos-1.2.0/test/conftest.py +212 -0
- skylos-1.2.0/test/test_analyzer.py +612 -0
- skylos-1.2.0/test/test_changes_analyzer.py +149 -0
- skylos-1.2.0/test/test_cli.py +353 -0
- skylos-1.2.0/test/test_constants.py +348 -0
- skylos-1.2.0/test/test_framework_aware.py +372 -0
- skylos-1.2.0/test/test_integration.py +320 -0
- skylos-1.2.0/test/test_test_aware.py +328 -0
- skylos-1.2.0/test/test_visitor.py +704 -0
- skylos-1.0.9/skylos/analyzer.py +0 -180
- skylos-1.0.9/test/test_visitor.py +0 -220
- {skylos-1.0.9 → skylos-1.2.0}/setup.cfg +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/dependency_links.txt +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/entry_points.txt +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/requires.txt +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/skylos.egg-info/top_level.txt +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/__init__.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/compare_tools.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/diagnostics.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/__init__.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/app.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/sample_repo/__init__.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/sample_repo/commands.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/sample_repo/models.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/sample_repo/routes.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/sample_repo/sample_repo/utils.py +0 -0
- {skylos-1.0.9 → skylos-1.2.0}/test/test_skylos.py +0 -0
|
@@ -10,12 +10,36 @@
|
|
|
10
10
|
|
|
11
11
|
> 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
12
|
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Features](#features)
|
|
16
|
+
- [Benchmark](#benchmark-you-can-find-this-benchmark-test-in-test-folder)
|
|
17
|
+
- [Installation](#installation)
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [Understanding Confidence Levels](#understanding-confidence-levels)
|
|
20
|
+
- [Test File Detection](#test-file-detection)
|
|
21
|
+
- [Folder Management](#folder-management)
|
|
22
|
+
- [CLI Options](#cli-options)
|
|
23
|
+
- [Example Output](#example-output)
|
|
24
|
+
- [Interactive Mode](#interactive-mode)
|
|
25
|
+
- [Development](#development)
|
|
26
|
+
- [FAQ](#faq)
|
|
27
|
+
- [Limitations](#limitations)
|
|
28
|
+
- [Troubleshooting](#troubleshooting)
|
|
29
|
+
- [Contributing](#contributing)
|
|
30
|
+
- [Roadmap](#roadmap)
|
|
31
|
+
- [License](#license)
|
|
32
|
+
- [Contact](#contact)
|
|
33
|
+
|
|
13
34
|
## Features
|
|
14
35
|
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
36
|
+
* **Framework-Aware Detection**: Attempt at handling Flask, Django, FastAPI routes and decorators
|
|
37
|
+
* **Test File Exclusion**: Auto excludes test files (you can include it back if you want)
|
|
38
|
+
* **Interactive Cleanup**: Select specific items to remove from CLI
|
|
39
|
+
* **Unused Functions & Methods**: Finds functions and methods that not called
|
|
40
|
+
* **Unused Classes**: Detects classes that are not instantiated or inherited
|
|
41
|
+
* **Unused Imports**: Identifies imports that are not used
|
|
42
|
+
* **Folder Management**: Inclusion/exclusion of directories
|
|
19
43
|
|
|
20
44
|
## Benchmark (You can find this benchmark test in `test` folder)
|
|
21
45
|
|
|
@@ -70,10 +94,97 @@ skylos --interactive --dry-run /path/to/your/project
|
|
|
70
94
|
|
|
71
95
|
# Output to JSON
|
|
72
96
|
skylos --json /path/to/your/project
|
|
97
|
+
|
|
98
|
+
# With confidence
|
|
99
|
+
skylos path/to/your/file --confidence 20 ## or whatever value u wanna set
|
|
73
100
|
```
|
|
74
101
|
|
|
75
|
-
##
|
|
102
|
+
## Understanding Confidence Levels
|
|
103
|
+
|
|
104
|
+
Skylos uses a confidence-based system to try to handle Python's dynamic nature and web frameworks.
|
|
105
|
+
|
|
106
|
+
### How Confidence Works
|
|
107
|
+
|
|
108
|
+
- **Confidence 100**: 100% unused (default imports, obvious dead functions)
|
|
109
|
+
- **Confidence 60**: Default value - conservative detection
|
|
110
|
+
- **Confidence 40**: Framework helpers, functions in web app files
|
|
111
|
+
- **Confidence 20**: Framework routes, decorated functions
|
|
112
|
+
- **Confidence 0**: Show everything
|
|
113
|
+
|
|
114
|
+
### Framework Detection
|
|
115
|
+
|
|
116
|
+
When Skylos detects web framework imports (Flask, Django, FastAPI), it applies different confidence levels:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# only obvious dead codes
|
|
120
|
+
skylos app.py --confidence 60 # THIS IS THE DEFAULT
|
|
121
|
+
|
|
122
|
+
# include unused helpers in framework files
|
|
123
|
+
skylos app.py --confidence 30
|
|
124
|
+
|
|
125
|
+
# include potentially unused routes
|
|
126
|
+
skylos app.py --confidence 20
|
|
76
127
|
|
|
128
|
+
# everything.. shows how all potential dead code
|
|
129
|
+
skylos app.py --confidence 0
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Test File Detection
|
|
133
|
+
|
|
134
|
+
Skylos automatically excludes test files from analysis because test code patterns often appear as "dead code" but are actually called by test frameworks. Should you need to include them in your test, just refer to the [Folder Management](#folder-management)
|
|
135
|
+
|
|
136
|
+
### What Gets Detected as Test Files
|
|
137
|
+
|
|
138
|
+
**File paths containing:**
|
|
139
|
+
- `/test/` or `/tests/` directories
|
|
140
|
+
- Files ending with `_test.py`
|
|
141
|
+
|
|
142
|
+
**Test imports:**
|
|
143
|
+
- `pytest`, `unittest`, `nose`, `mock`, `responses`
|
|
144
|
+
|
|
145
|
+
**Test decorators:**
|
|
146
|
+
- `@pytest.fixture`, `@pytest.mark.*`
|
|
147
|
+
- `@patch`, `@mock.*`
|
|
148
|
+
|
|
149
|
+
### Test File Behavior
|
|
150
|
+
|
|
151
|
+
When Skylos detects a test file, it by default, will apply a confidence penalty of 100, which will essentially filter out all dead code detection
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# This will show 0 dead code because its treated as test file
|
|
155
|
+
/project/tests/test_user.py
|
|
156
|
+
/project/test/helper.py
|
|
157
|
+
/project/utils_test.py
|
|
158
|
+
|
|
159
|
+
# The files will be analyzed normally. Note that it doesn't end with _test.py
|
|
160
|
+
/project/user.py
|
|
161
|
+
/project/test_data.py
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Including & Excluding Files
|
|
165
|
+
|
|
166
|
+
### Default Exclusions
|
|
167
|
+
By default, Skylos excludes common folders: `__pycache__`, `.git`, `.pytest_cache`, `.mypy_cache`, `.tox`, `htmlcov`, `.coverage`, `build`, `dist`, `*.egg-info`, `venv`, `.venv`
|
|
168
|
+
|
|
169
|
+
### Folder Options
|
|
170
|
+
```bash
|
|
171
|
+
# List default excluded folders
|
|
172
|
+
skylos --list-default-excludes
|
|
173
|
+
|
|
174
|
+
# Exclude single folder (The example here will be venv)
|
|
175
|
+
skylos /path/to/your/project --exclude-folder venv
|
|
176
|
+
|
|
177
|
+
# Exclude multiple folders
|
|
178
|
+
skylos /path/to/your/project --exclude-folder venv --exclude-folder build
|
|
179
|
+
|
|
180
|
+
# Force include normally excluded folders
|
|
181
|
+
skylos /path/to/your/project --include-folder venv
|
|
182
|
+
|
|
183
|
+
# Scan everything (no exclusions)
|
|
184
|
+
skylos path/to/your/project --no-default-excludes
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## CLI Options
|
|
77
188
|
```
|
|
78
189
|
Usage: skylos [OPTIONS] PATH
|
|
79
190
|
|
|
@@ -81,51 +192,60 @@ Arguments:
|
|
|
81
192
|
PATH Path to the Python project to analyze
|
|
82
193
|
|
|
83
194
|
Options:
|
|
84
|
-
-h, --help
|
|
85
|
-
-j, --json
|
|
86
|
-
-o, --output FILE
|
|
87
|
-
-v, --verbose
|
|
88
|
-
-i, --interactive
|
|
89
|
-
--dry-run
|
|
195
|
+
-h, --help Show this help message and exit
|
|
196
|
+
-j, --json Output raw JSON instead of formatted text
|
|
197
|
+
-o, --output FILE Write output to file instead of stdout
|
|
198
|
+
-v, --verbose Enable verbose output
|
|
199
|
+
-i, --interactive Interactively select items to remove
|
|
200
|
+
--dry-run Show what would be removed without modifying files
|
|
201
|
+
--exclude-folder FOLDER Exclude a folder from analysis (can be used multiple times)
|
|
202
|
+
--include-folder FOLDER Force include a folder that would otherwise be excluded
|
|
203
|
+
--no-default-excludes Don't exclude default folders (__pycache__, .git, venv, etc.)
|
|
204
|
+
--list-default-excludes List the default excluded folders and
|
|
205
|
+
-c, --confidence LEVEL Confidence threshold (0-100). Lower values will show more items.
|
|
90
206
|
```
|
|
91
207
|
|
|
92
208
|
## Example Output
|
|
93
209
|
|
|
94
210
|
```
|
|
95
|
-
|
|
211
|
+
Python Static Analysis Results
|
|
96
212
|
===================================
|
|
97
213
|
|
|
98
214
|
Summary:
|
|
99
215
|
• Unreachable functions: 48
|
|
100
216
|
• Unused imports: 8
|
|
101
217
|
|
|
102
|
-
|
|
218
|
+
Unreachable Functions
|
|
103
219
|
========================
|
|
220
|
+
|
|
104
221
|
1. module_13.test_function
|
|
105
222
|
└─ /Users/oha/project/module_13.py:5
|
|
106
223
|
2. module_13.unused_function
|
|
107
224
|
└─ /Users/oha/project/module_13.py:13
|
|
108
225
|
...
|
|
109
226
|
|
|
110
|
-
|
|
227
|
+
|
|
228
|
+
Unused Imports
|
|
111
229
|
=================
|
|
230
|
+
|
|
112
231
|
1. os
|
|
113
232
|
└─ /Users/oha/project/module_13.py:1
|
|
114
233
|
2. json
|
|
115
234
|
└─ /Users/oha/project/module_13.py:3
|
|
116
235
|
...
|
|
236
|
+
```
|
|
117
237
|
|
|
118
238
|
Next steps:
|
|
119
|
-
|
|
120
|
-
• Use
|
|
121
|
-
|
|
239
|
+
|
|
240
|
+
• Use `--interactive` to select specific items to remove
|
|
241
|
+
|
|
242
|
+
• Use `--dry-run` to preview changes before applying them
|
|
243
|
+
|
|
122
244
|
|
|
123
245
|
## Interactive Mode
|
|
124
246
|
|
|
125
247
|
The interactive mode lets you select specific functions and imports to remove:
|
|
126
248
|
|
|
127
|
-

|
|
128
|
-
|
|
129
249
|
1. **Select items**: Use arrow keys and space to select/deselect
|
|
130
250
|
2. **Confirm changes**: Review selected items before applying
|
|
131
251
|
3. **Auto-cleanup**: Files are automatically updated
|
|
@@ -174,14 +294,20 @@ A: No. Always review results manually, especially for framework code, APIs, and
|
|
|
174
294
|
**Q: Why did Ruff underperform?**
|
|
175
295
|
A: Like all other tools, Ruff is focused on detecting specific, surface-level issues. Tools like Vulture and Skylos are built SPECIFICALLY for dead code detection. It is NOT a specialized dead code detector. If your goal is dead code, then ruff is the wrong tool. It is a good tool but it's like using a wrench to hammer a nail. Good tool, wrong purpose.
|
|
176
296
|
|
|
297
|
+
**Q: Why doesn't Skylos detect my unused Flask routes?**
|
|
298
|
+
A: Web framework routes are given low confidence (20) because they might be called by external HTTP requests. Use `--confidence 20` to see them. We acknowledge there are current limitations to this approach so use it sparingly.
|
|
299
|
+
|
|
300
|
+
**Q: What confidence level should I use?**
|
|
301
|
+
A: Start with 60 (default) for safe cleanup. Use 30 for framework applications. Use 20 for more comprehensive auditing.
|
|
302
|
+
|
|
177
303
|
## Limitations
|
|
178
304
|
|
|
179
305
|
- **Dynamic code**: `getattr()`, `globals()`, runtime imports are hard to detect
|
|
180
|
-
- **Frameworks**: Django models, Flask routes may appear unused but aren't
|
|
306
|
+
- **Frameworks**: Django models, Flask, FastAPI routes may appear unused but aren't
|
|
181
307
|
- **Test data**: Limited scenarios, your mileage may vary
|
|
182
308
|
- **False positives**: Always manually review before deleting code
|
|
183
309
|
|
|
184
|
-
If we can detect 100% of all dead code in any structure, we wouldn't be sitting here. But we definitely tried our best
|
|
310
|
+
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.
|
|
185
311
|
|
|
186
312
|
## Troubleshooting
|
|
187
313
|
|
|
@@ -212,14 +338,11 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
|
|
|
212
338
|
5. Open a Pull Request
|
|
213
339
|
|
|
214
340
|
## Roadmap
|
|
215
|
-
|
|
216
|
-
- [ ] Expand our test cases
|
|
341
|
+
- [x] Expand our test cases
|
|
217
342
|
- [ ] Configuration file support
|
|
218
|
-
- [ ] Custom analysis rules
|
|
219
343
|
- [ ] Git hooks integration
|
|
220
344
|
- [ ] CI/CD integration examples
|
|
221
|
-
|
|
222
|
-
- [ ] Support for other languages
|
|
345
|
+
~~- [] Support for other languages (unless I have contributors, this ain't possible)~~
|
|
223
346
|
- [ ] Further optimization
|
|
224
347
|
|
|
225
348
|
## License
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import ast
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from skylos.visitor import Visitor
|
|
9
|
+
from skylos.constants import ( DEFAULT_EXCLUDE_FOLDERS, PENALTIES, AUTO_CALLED )
|
|
10
|
+
from skylos.test_aware import TestAwareVisitor
|
|
11
|
+
import os
|
|
12
|
+
import traceback
|
|
13
|
+
from skylos.framework_aware import FrameworkAwareVisitor, detect_framework_usage
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(message)s')
|
|
16
|
+
logger=logging.getLogger('Skylos')
|
|
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
|
+
class Skylos:
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.defs={}
|
|
36
|
+
self.refs=[]
|
|
37
|
+
self.dynamic=set()
|
|
38
|
+
self.exports=defaultdict(set)
|
|
39
|
+
|
|
40
|
+
def _module(self,root,f):
|
|
41
|
+
p=list(f.relative_to(root).parts)
|
|
42
|
+
if p[-1].endswith(".py"):p[-1]=p[-1][:-3]
|
|
43
|
+
if p[-1]=="__init__":p.pop()
|
|
44
|
+
return".".join(p)
|
|
45
|
+
|
|
46
|
+
def _should_exclude_file(self, file_path, root_path, exclude_folders):
|
|
47
|
+
if not exclude_folders:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
rel_path = file_path.relative_to(root_path)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
path_parts = rel_path.parts
|
|
56
|
+
|
|
57
|
+
for exclude_folder in exclude_folders:
|
|
58
|
+
if "*" in exclude_folder:
|
|
59
|
+
for part in path_parts:
|
|
60
|
+
if part.endswith(exclude_folder.replace("*", "")):
|
|
61
|
+
return True
|
|
62
|
+
else:
|
|
63
|
+
if exclude_folder in path_parts:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def _get_python_files(self, path, exclude_folders=None):
|
|
69
|
+
p = Path(path).resolve()
|
|
70
|
+
|
|
71
|
+
if p.is_file():
|
|
72
|
+
return [p], p.parent
|
|
73
|
+
|
|
74
|
+
root = p
|
|
75
|
+
all_files = list(p.glob("**/*.py"))
|
|
76
|
+
|
|
77
|
+
if exclude_folders:
|
|
78
|
+
filtered_files = []
|
|
79
|
+
excluded_count = 0
|
|
80
|
+
|
|
81
|
+
for file_path in all_files:
|
|
82
|
+
if self._should_exclude_file(file_path, root, exclude_folders):
|
|
83
|
+
excluded_count += 1
|
|
84
|
+
continue
|
|
85
|
+
filtered_files.append(file_path)
|
|
86
|
+
|
|
87
|
+
if excluded_count > 0:
|
|
88
|
+
logger.info(f"Excluded {excluded_count} files from analysis")
|
|
89
|
+
|
|
90
|
+
return filtered_files, root
|
|
91
|
+
|
|
92
|
+
return all_files, root
|
|
93
|
+
|
|
94
|
+
def _mark_exports(self):
|
|
95
|
+
for name, d in self.defs.items():
|
|
96
|
+
if d.in_init and not d.simple_name.startswith('_'):
|
|
97
|
+
d.is_exported = True
|
|
98
|
+
|
|
99
|
+
for mod, export_names in self.exports.items():
|
|
100
|
+
for name in export_names:
|
|
101
|
+
for def_name, def_obj in self.defs.items():
|
|
102
|
+
if (def_name.startswith(f"{mod}.") and
|
|
103
|
+
def_obj.simple_name == name and
|
|
104
|
+
def_obj.type != "import"):
|
|
105
|
+
def_obj.is_exported = True
|
|
106
|
+
|
|
107
|
+
def _mark_refs(self):
|
|
108
|
+
import_to_original = {}
|
|
109
|
+
for name, def_obj in self.defs.items():
|
|
110
|
+
if def_obj.type == "import":
|
|
111
|
+
import_name = name.split('.')[-1]
|
|
112
|
+
|
|
113
|
+
for def_name, orig_def in self.defs.items():
|
|
114
|
+
if (orig_def.type != "import" and
|
|
115
|
+
orig_def.simple_name == import_name and
|
|
116
|
+
def_name != name):
|
|
117
|
+
import_to_original[name] = def_name
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
simple_name_lookup = defaultdict(list)
|
|
121
|
+
for d in self.defs.values():
|
|
122
|
+
simple_name_lookup[d.simple_name].append(d)
|
|
123
|
+
|
|
124
|
+
for ref, _ in self.refs:
|
|
125
|
+
if ref in self.defs:
|
|
126
|
+
self.defs[ref].references += 1
|
|
127
|
+
|
|
128
|
+
if ref in import_to_original:
|
|
129
|
+
original = import_to_original[ref]
|
|
130
|
+
self.defs[original].references += 1
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
simple = ref.split('.')[-1]
|
|
134
|
+
matches = simple_name_lookup.get(simple, [])
|
|
135
|
+
for d in matches:
|
|
136
|
+
d.references += 1
|
|
137
|
+
|
|
138
|
+
for module_name in self.dynamic:
|
|
139
|
+
for def_name, def_obj in self.defs.items():
|
|
140
|
+
if def_obj.name.startswith(f"{module_name}."):
|
|
141
|
+
if def_obj.type in ("function", "method") and not def_obj.simple_name.startswith("_"):
|
|
142
|
+
def_obj.references += 1
|
|
143
|
+
|
|
144
|
+
def _get_base_classes(self, class_name):
|
|
145
|
+
if class_name not in self.defs:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
class_def = self.defs[class_name]
|
|
149
|
+
|
|
150
|
+
if hasattr(class_def, 'base_classes'):
|
|
151
|
+
return class_def.base_classes
|
|
152
|
+
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
def _apply_penalties(self, def_obj, visitor, framework):
|
|
156
|
+
c = 100
|
|
157
|
+
|
|
158
|
+
if def_obj.simple_name.startswith("_") and not def_obj.simple_name.startswith("__"):
|
|
159
|
+
c -= PENALTIES["private_name"]
|
|
160
|
+
if def_obj.simple_name.startswith("__") and def_obj.simple_name.endswith("__"):
|
|
161
|
+
c -= PENALTIES["dunder_or_magic"]
|
|
162
|
+
if def_obj.type == "variable" and def_obj.simple_name == "_":
|
|
163
|
+
c -= PENALTIES["underscored_var"]
|
|
164
|
+
if def_obj.in_init and def_obj.type in ("function", "class"):
|
|
165
|
+
c -= PENALTIES["in_init_file"]
|
|
166
|
+
if def_obj.name.split(".")[0] in self.dynamic:
|
|
167
|
+
c -= PENALTIES["dynamic_module"]
|
|
168
|
+
if visitor.is_test_file or def_obj.line in visitor.test_decorated_lines:
|
|
169
|
+
c -= PENALTIES["test_related"]
|
|
170
|
+
|
|
171
|
+
framework_confidence = detect_framework_usage(def_obj, visitor=framework)
|
|
172
|
+
if framework_confidence is not None:
|
|
173
|
+
c = min(c, framework_confidence)
|
|
174
|
+
|
|
175
|
+
if (def_obj.simple_name.startswith("__") and def_obj.simple_name.endswith("__")):
|
|
176
|
+
c = 0
|
|
177
|
+
|
|
178
|
+
if def_obj.type == "parameter":
|
|
179
|
+
if def_obj.simple_name in ("self", "cls"):
|
|
180
|
+
c = 0
|
|
181
|
+
elif "." in def_obj.name:
|
|
182
|
+
method_name = def_obj.name.split(".")[-2]
|
|
183
|
+
if method_name.startswith("__") and method_name.endswith("__"):
|
|
184
|
+
c = 0
|
|
185
|
+
|
|
186
|
+
if visitor.is_test_file or def_obj.line in visitor.test_decorated_lines:
|
|
187
|
+
c = 0
|
|
188
|
+
|
|
189
|
+
if (def_obj.type == "import" and def_obj.name.startswith("__future__.") and
|
|
190
|
+
def_obj.simple_name in ("annotations", "absolute_import", "division",
|
|
191
|
+
"print_function", "unicode_literals", "generator_stop")):
|
|
192
|
+
c = 0
|
|
193
|
+
|
|
194
|
+
def_obj.confidence = max(c, 0)
|
|
195
|
+
|
|
196
|
+
def _apply_heuristics(self):
|
|
197
|
+
class_methods = defaultdict(list)
|
|
198
|
+
for d in self.defs.values():
|
|
199
|
+
if d.type in ("method", "function") and "." in d.name:
|
|
200
|
+
cls = d.name.rsplit(".", 1)[0]
|
|
201
|
+
if cls in self.defs and self.defs[cls].type == "class":
|
|
202
|
+
class_methods[cls].append(d)
|
|
203
|
+
|
|
204
|
+
for cls, methods in class_methods.items():
|
|
205
|
+
if self.defs[cls].references > 0:
|
|
206
|
+
for m in methods:
|
|
207
|
+
if m.simple_name in AUTO_CALLED: # __init__, __enter__, __exit__
|
|
208
|
+
m.references += 1
|
|
209
|
+
|
|
210
|
+
def analyze(self, path, thr=60, exclude_folders=None):
|
|
211
|
+
files, root = self._get_python_files(path, exclude_folders)
|
|
212
|
+
|
|
213
|
+
if not files:
|
|
214
|
+
logger.warning(f"No Python files found in {path}")
|
|
215
|
+
return json.dumps({
|
|
216
|
+
"unused_functions": [],
|
|
217
|
+
"unused_imports": [],
|
|
218
|
+
"unused_classes": [],
|
|
219
|
+
"unused_variables": [],
|
|
220
|
+
"unused_parameters": [],
|
|
221
|
+
"analysis_summary": {
|
|
222
|
+
"total_files": 0,
|
|
223
|
+
"excluded_folders": exclude_folders if exclude_folders else []
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
logger.info(f"Analyzing {len(files)} Python files...")
|
|
228
|
+
|
|
229
|
+
modmap = {}
|
|
230
|
+
for f in files:
|
|
231
|
+
modmap[f] = self._module(root, f)
|
|
232
|
+
|
|
233
|
+
for file in files:
|
|
234
|
+
mod = modmap[file]
|
|
235
|
+
defs, refs, dyn, exports, test_flags, framework_flags = proc_file(file, mod)
|
|
236
|
+
|
|
237
|
+
# apply penalties while we still have the file-specific flags
|
|
238
|
+
for d in defs:
|
|
239
|
+
self._apply_penalties(d, test_flags, framework_flags)
|
|
240
|
+
self.defs[d.name] = d
|
|
241
|
+
|
|
242
|
+
self.refs.extend(refs)
|
|
243
|
+
self.dynamic.update(dyn)
|
|
244
|
+
self.exports[mod].update(exports)
|
|
245
|
+
|
|
246
|
+
self._mark_refs()
|
|
247
|
+
self._apply_heuristics()
|
|
248
|
+
self._mark_exports()
|
|
249
|
+
|
|
250
|
+
thr = max(0, thr)
|
|
251
|
+
|
|
252
|
+
unused = []
|
|
253
|
+
for d in self.defs.values():
|
|
254
|
+
if d.references == 0 and not d.is_exported and d.confidence > 0 and d.confidence >= thr:
|
|
255
|
+
unused.append(d.to_dict())
|
|
256
|
+
|
|
257
|
+
result = {
|
|
258
|
+
"unused_functions": [],
|
|
259
|
+
"unused_imports": [],
|
|
260
|
+
"unused_classes": [],
|
|
261
|
+
"unused_variables": [],
|
|
262
|
+
"unused_parameters": [],
|
|
263
|
+
"analysis_summary": {
|
|
264
|
+
"total_files": len(files),
|
|
265
|
+
"excluded_folders": exclude_folders if exclude_folders else [],
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for u in unused:
|
|
270
|
+
if u["type"] in ("function", "method"):
|
|
271
|
+
result["unused_functions"].append(u)
|
|
272
|
+
elif u["type"] == "import":
|
|
273
|
+
result["unused_imports"].append(u)
|
|
274
|
+
elif u["type"] == "class":
|
|
275
|
+
result["unused_classes"].append(u)
|
|
276
|
+
elif u["type"] == "variable":
|
|
277
|
+
result["unused_variables"].append(u)
|
|
278
|
+
elif u["type"] == "parameter":
|
|
279
|
+
result["unused_parameters"].append(u)
|
|
280
|
+
|
|
281
|
+
return json.dumps(result, indent=2)
|
|
282
|
+
|
|
283
|
+
def proc_file(file_or_args, mod=None):
|
|
284
|
+
if mod is None and isinstance(file_or_args, tuple):
|
|
285
|
+
file, mod = file_or_args
|
|
286
|
+
else:
|
|
287
|
+
file = file_or_args
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
source = Path(file).read_text(encoding="utf-8")
|
|
291
|
+
tree = ast.parse(source)
|
|
292
|
+
|
|
293
|
+
tv = TestAwareVisitor(filename=file)
|
|
294
|
+
tv.visit(tree)
|
|
295
|
+
|
|
296
|
+
fv = FrameworkAwareVisitor(filename=file)
|
|
297
|
+
fv.visit(tree)
|
|
298
|
+
|
|
299
|
+
v = Visitor(mod, file)
|
|
300
|
+
v.visit(tree)
|
|
301
|
+
|
|
302
|
+
return v.defs, v.refs, v.dyn, v.exports, tv, fv
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.error(f"{file}: {e}")
|
|
305
|
+
if os.getenv("SKYLOS_DEBUG"):
|
|
306
|
+
logger.error(traceback.format_exc())
|
|
307
|
+
dummy_visitor = TestAwareVisitor(filename=file)
|
|
308
|
+
dummy_framework_visitor = FrameworkAwareVisitor(filename=file)
|
|
309
|
+
|
|
310
|
+
return [], [], set(), set(), dummy_visitor, dummy_framework_visitor
|
|
311
|
+
|
|
312
|
+
def analyze(path,conf=60, exclude_folders=None):
|
|
313
|
+
return Skylos().analyze(path,conf, exclude_folders)
|
|
314
|
+
|
|
315
|
+
if __name__=="__main__":
|
|
316
|
+
if len(sys.argv)>1:
|
|
317
|
+
p=sys.argv[1];c=int(sys.argv[2])if len(sys.argv)>2 else 60
|
|
318
|
+
result = analyze(p,c)
|
|
319
|
+
|
|
320
|
+
data = json.loads(result)
|
|
321
|
+
print("\n🔍 Python Static Analysis Results")
|
|
322
|
+
print("===================================\n")
|
|
323
|
+
|
|
324
|
+
total_items = sum(len(items) for items in data.values())
|
|
325
|
+
|
|
326
|
+
print("Summary:")
|
|
327
|
+
if data["unused_functions"]:
|
|
328
|
+
print(f" • Unreachable functions: {len(data['unused_functions'])}")
|
|
329
|
+
if data["unused_imports"]:
|
|
330
|
+
print(f" • Unused imports: {len(data['unused_imports'])}")
|
|
331
|
+
if data["unused_classes"]:
|
|
332
|
+
print(f" • Unused classes: {len(data['unused_classes'])}")
|
|
333
|
+
if data["unused_variables"]:
|
|
334
|
+
print(f" • Unused variables: {len(data['unused_variables'])}")
|
|
335
|
+
|
|
336
|
+
if data["unused_functions"]:
|
|
337
|
+
print("\n📦 Unreachable Functions")
|
|
338
|
+
print("=======================")
|
|
339
|
+
for i, func in enumerate(data["unused_functions"], 1):
|
|
340
|
+
print(f" {i}. {func['name']}")
|
|
341
|
+
print(f" └─ {func['file']}:{func['line']}")
|
|
342
|
+
|
|
343
|
+
if data["unused_imports"]:
|
|
344
|
+
print("\n📥 Unused Imports")
|
|
345
|
+
print("================")
|
|
346
|
+
for i, imp in enumerate(data["unused_imports"], 1):
|
|
347
|
+
print(f" {i}. {imp['simple_name']}")
|
|
348
|
+
print(f" └─ {imp['file']}:{imp['line']}")
|
|
349
|
+
|
|
350
|
+
if data["unused_classes"]:
|
|
351
|
+
print("\n📋 Unused Classes")
|
|
352
|
+
print("=================")
|
|
353
|
+
for i, cls in enumerate(data["unused_classes"], 1):
|
|
354
|
+
print(f" {i}. {cls['name']}")
|
|
355
|
+
print(f" └─ {cls['file']}:{cls['line']}")
|
|
356
|
+
|
|
357
|
+
if data["unused_variables"]:
|
|
358
|
+
print("\n📊 Unused Variables")
|
|
359
|
+
print("==================")
|
|
360
|
+
for i, var in enumerate(data["unused_variables"], 1):
|
|
361
|
+
print(f" {i}. {var['name']}")
|
|
362
|
+
print(f" └─ {var['file']}:{var['line']}")
|
|
363
|
+
|
|
364
|
+
print("\n" + "─" * 50)
|
|
365
|
+
print(f"Found {total_items} dead code items. Add this badge to your README:")
|
|
366
|
+
print(f"```markdown")
|
|
367
|
+
print(f"")
|
|
368
|
+
print(f"```")
|
|
369
|
+
|
|
370
|
+
print("\nNext steps:")
|
|
371
|
+
print(" • Use --interactive to select specific items to remove")
|
|
372
|
+
print(" • Use --dry-run to preview changes before applying them")
|
|
373
|
+
else:
|
|
374
|
+
print("Usage: python Skylos.py <path> [confidence_threshold]")
|