skylos 0.0.1__tar.gz → 1.0.8__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.8/PKG-INFO +8 -0
- skylos-1.0.8/README.md +233 -0
- skylos-1.0.8/pyproject.toml +14 -0
- skylos-1.0.8/setup.cfg +4 -0
- skylos-1.0.8/setup.py +26 -0
- skylos-1.0.8/skylos/__init__.py +8 -0
- skylos-1.0.8/skylos/analyzer.py +180 -0
- skylos-1.0.8/skylos/visitor.py +181 -0
- skylos-1.0.8/skylos.egg-info/PKG-INFO +8 -0
- skylos-1.0.8/skylos.egg-info/SOURCES.txt +25 -0
- skylos-1.0.8/skylos.egg-info/dependency_links.txt +1 -0
- skylos-1.0.8/skylos.egg-info/entry_points.txt +2 -0
- skylos-1.0.8/skylos.egg-info/requires.txt +1 -0
- skylos-1.0.8/skylos.egg-info/top_level.txt +2 -0
- skylos-1.0.8/test/compare_tools.py +604 -0
- skylos-1.0.8/test/diagnostics.py +364 -0
- skylos-1.0.8/test/sample_repo/app.py +13 -0
- skylos-1.0.8/test/sample_repo/sample_repo/commands.py +81 -0
- skylos-1.0.8/test/sample_repo/sample_repo/models.py +122 -0
- skylos-1.0.8/test/sample_repo/sample_repo/routes.py +89 -0
- skylos-1.0.8/test/sample_repo/sample_repo/utils.py +36 -0
- skylos-1.0.8/test/test_skylos.py +456 -0
- skylos-1.0.8/test/test_visitor.py +220 -0
- skylos-0.0.1/.DS_Store +0 -0
- skylos-0.0.1/.gitignore +0 -17
- skylos-0.0.1/Cargo.lock +0 -712
- skylos-0.0.1/Cargo.toml +0 -27
- skylos-0.0.1/License +0 -13
- skylos-0.0.1/PKG-INFO +0 -295
- skylos-0.0.1/README.md +0 -288
- skylos-0.0.1/build.rs +0 -57
- skylos-0.0.1/pyproject.toml +0 -16
- skylos-0.0.1/skylos/__init__.py +0 -30
- skylos-0.0.1/src/lib.rs +0 -1232
- skylos-0.0.1/src/main.rs +0 -12
- skylos-0.0.1/src/queries.rs +0 -34
- skylos-0.0.1/src/types.rs +0 -16
- skylos-0.0.1/src/utils.rs +0 -46
- skylos-0.0.1/test/benchmark/benchmarks.py +0 -259
- skylos-0.0.1/test/sample_project/__init__.py +0 -5
- skylos-0.0.1/test/sample_project/module1.py +0 -15
- skylos-0.0.1/test/sample_project/module2.py +0 -12
- skylos-0.0.1/test/sample_project/module3.py +0 -25
- skylos-0.0.1/test/sample_project/module4.py +0 -45
- skylos-0.0.1/test/sample_project/module5.py +0 -38
- skylos-0.0.1/test/sample_project/module6.py +0 -44
- skylos-0.0.1/test/sample_project/module8.py +0 -24
- skylos-0.0.1/test/sample_project/module_13.py +0 -15
- skylos-0.0.1/test/sample_project/subpackage/__init__.py +0 -5
- skylos-0.0.1/test/sample_project/subpackage/module3.py +0 -54
- skylos-0.0.1/test/sample_project/subpackage/module7.py +0 -41
- skylos-0.0.1/test/sample_project/subpackage2/folder1/__init__.py +0 -42
- skylos-0.0.1/test/sample_project/subpackage2/folder1/module11.py +0 -17
- skylos-0.0.1/test/sample_project/subpackage2/folder1/module12.py +0 -35
- skylos-0.0.1/test/sample_project/subpackage2/folder2/folder1/module10.py +0 -54
- skylos-0.0.1/test/test_cli.py +0 -13
- skylos-0.0.1/test/test_sanity.py +0 -15
- skylos-0.0.1/test/test_script.py +0 -2
- skylos-0.0.1/test/test_skylos.py +0 -24
- skylos-0.0.1/test/test_skylos_calls.py +0 -27
- skylos-0.0.1/test/test_skylos_main_block.py +0 -27
- skylos-0.0.1/test/test_skylos_pyapi.py +0 -9
- {skylos-0.0.1 → skylos-1.0.8}/skylos/cli.py +0 -0
- {skylos-0.0.1 → skylos-1.0.8}/test/__init__.py +0 -0
- {skylos-0.0.1/test/sample_project/subpackage2 → skylos-1.0.8/test/sample_repo}/__init__.py +0 -0
- {skylos-0.0.1/test/sample_project/subpackage2/folder2/folder1 → skylos-1.0.8/test/sample_repo/sample_repo}/__init__.py +0 -0
skylos-1.0.8/PKG-INFO
ADDED
skylos-1.0.8/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Skylos 🔍
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
<div align="center">
|
|
8
|
+
<img src="assets/SKYLOS.png" alt="Skylos Logo" width="200">
|
|
9
|
+
</div>
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
* Unused Functions & Methods: Finds functions and methods that are never called
|
|
16
|
+
* Unused Classes: Detects classes that are never instantiated or inherited
|
|
17
|
+
* Unused Imports: Identifies imports that serve no purpose
|
|
18
|
+
* Cross-Module Tracking: Analyzes usage patterns across your entire codebase
|
|
19
|
+
|
|
20
|
+
## Benchmark (You can find this benchmark test in `test` folder)
|
|
21
|
+
|
|
22
|
+
The benchmark checks how well static analysis tools spot dead code in Python. Things such as unused functions, classes, imports, variables, that kinda stuff. To read more refer down below.
|
|
23
|
+
|
|
24
|
+
**The methodology and process for benchmarking can be found in `BENCHMARK.md`**
|
|
25
|
+
|
|
26
|
+
| Tool | Time (s) | Items | TP | FP | FN | Precision | Recall | F1 Score |
|
|
27
|
+
|------|----------|-------|----|----|----|-----------|---------|---------|
|
|
28
|
+
| **Skylos (Local Dev)** | **0.013** | **34** | **22** | **12** | **7** | **0.6471** | **0.7586** | **0.6984** |
|
|
29
|
+
| Vulture (0%) | 0.054 | 32 | 11 | 20 | 18 | 0.3548 | 0.3793 | 0.3667 |
|
|
30
|
+
| Vulture (60%) | 0.044 | 32 | 11 | 20 | 18 | 0.3548 | 0.3793 | 0.3667 |
|
|
31
|
+
| Flake8 | 0.371 | 16 | 5 | 7 | 24 | 0.4167 | 0.1724 | 0.2439 |
|
|
32
|
+
| Pylint | 0.705 | 11 | 0 | 8 | 29 | 0.0000 | 0.0000 | 0.0000 |
|
|
33
|
+
| Ruff | 0.140 | 16 | 5 | 7 | 24 | 0.4167 | 0.1724 | 0.2439 |
|
|
34
|
+
|
|
35
|
+
To run the benchmark:
|
|
36
|
+
`python compare_tools.py /path/to/sample_repo`
|
|
37
|
+
|
|
38
|
+
**Note: More can be found in `BENCHMARK.md`**
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### Basic Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install skylos
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### From Source
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Clone the repository
|
|
52
|
+
git clone https://github.com/duriantaco/skylos.git
|
|
53
|
+
cd skylos
|
|
54
|
+
|
|
55
|
+
## Install your dependencies
|
|
56
|
+
pip install .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Analyze a project
|
|
63
|
+
skylos /path/to/your/project
|
|
64
|
+
|
|
65
|
+
# Interactive mode - select items to remove
|
|
66
|
+
skylos --interactive /path/to/your/project
|
|
67
|
+
|
|
68
|
+
# Dry run - see what would be removed
|
|
69
|
+
skylos --interactive --dry-run /path/to/your/project
|
|
70
|
+
|
|
71
|
+
# Output to JSON
|
|
72
|
+
skylos --json /path/to/your/project
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## CLI Options
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Usage: skylos [OPTIONS] PATH
|
|
79
|
+
|
|
80
|
+
Arguments:
|
|
81
|
+
PATH Path to the Python project to analyze
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
-h, --help Show this help message and exit
|
|
85
|
+
-j, --json Output raw JSON instead of formatted text
|
|
86
|
+
-o, --output FILE Write output to file instead of stdout
|
|
87
|
+
-v, --verbose Enable verbose output
|
|
88
|
+
-i, --interactive Interactively select items to remove
|
|
89
|
+
--dry-run Show what would be removed without modifying files
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Example Output
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
🔍 Python Static Analysis Results
|
|
96
|
+
===================================
|
|
97
|
+
|
|
98
|
+
Summary:
|
|
99
|
+
• Unreachable functions: 48
|
|
100
|
+
• Unused imports: 8
|
|
101
|
+
|
|
102
|
+
📦 Unreachable Functions
|
|
103
|
+
========================
|
|
104
|
+
1. module_13.test_function
|
|
105
|
+
└─ /Users/oha/project/module_13.py:5
|
|
106
|
+
2. module_13.unused_function
|
|
107
|
+
└─ /Users/oha/project/module_13.py:13
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
📥 Unused Imports
|
|
111
|
+
=================
|
|
112
|
+
1. os
|
|
113
|
+
└─ /Users/oha/project/module_13.py:1
|
|
114
|
+
2. json
|
|
115
|
+
└─ /Users/oha/project/module_13.py:3
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
Next steps:
|
|
119
|
+
• Use --interactive to select specific items to remove
|
|
120
|
+
• Use --dry-run to preview changes before applying them
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Interactive Mode
|
|
124
|
+
|
|
125
|
+
The interactive mode lets you select specific functions and imports to remove:
|
|
126
|
+
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
1. **Select items**: Use arrow keys and space to select/deselect
|
|
130
|
+
2. **Confirm changes**: Review selected items before applying
|
|
131
|
+
3. **Auto-cleanup**: Files are automatically updated
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
### Prerequisites
|
|
136
|
+
|
|
137
|
+
- `Python ≥3.9`
|
|
138
|
+
- `pytest`
|
|
139
|
+
- `inquirer`
|
|
140
|
+
|
|
141
|
+
### Setup
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Clone the repository
|
|
145
|
+
git clone https://github.com/duriantaco/skylos.git
|
|
146
|
+
cd skylos
|
|
147
|
+
|
|
148
|
+
# Create a virtual environment
|
|
149
|
+
python -m venv venv
|
|
150
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Running Tests
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Run Python tests
|
|
157
|
+
python -m pytest tests/
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## FAQ
|
|
161
|
+
|
|
162
|
+
**Q: Why doesn't Skylos find 100% of dead code?**
|
|
163
|
+
A: Python's dynamic features (getattr, globals, etc.) can't be perfectly analyzed statically. No tool can achieve 100% accuracy. If they say they can, they're lying.
|
|
164
|
+
|
|
165
|
+
**Q: Why are the results different on my codebase?**
|
|
166
|
+
A: These benchmarks use specific test cases. Your code patterns (frameworks, legacy code, etc.) will give different results.
|
|
167
|
+
|
|
168
|
+
**Q: Are these benchmarks realistic?**
|
|
169
|
+
A: They test common scenarios but can't cover every edge case. Use them as a guide, not gospel.
|
|
170
|
+
|
|
171
|
+
**Q: Should I automatically delete everything flagged as unused?**
|
|
172
|
+
A: No. Always review results manually, especially for framework code, APIs, and test utilities.
|
|
173
|
+
|
|
174
|
+
**Q: Why did Ruff underperform?**
|
|
175
|
+
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
|
+
|
|
177
|
+
## Limitations
|
|
178
|
+
|
|
179
|
+
- **Dynamic code**: `getattr()`, `globals()`, runtime imports are hard to detect
|
|
180
|
+
- **Frameworks**: Django models, Flask routes may appear unused but aren't
|
|
181
|
+
- **Test data**: Limited scenarios, your mileage may vary
|
|
182
|
+
- **False positives**: Always manually review before deleting code
|
|
183
|
+
|
|
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
|
|
185
|
+
|
|
186
|
+
## Troubleshooting
|
|
187
|
+
|
|
188
|
+
### Common Issues
|
|
189
|
+
|
|
190
|
+
1. **Permission Errors**
|
|
191
|
+
```
|
|
192
|
+
Error: Permission denied when removing function
|
|
193
|
+
```
|
|
194
|
+
Check file permissions before running in interactive mode.
|
|
195
|
+
|
|
196
|
+
2. **Missing Dependencies**
|
|
197
|
+
```
|
|
198
|
+
Interactive mode requires 'inquirer' package
|
|
199
|
+
```
|
|
200
|
+
Install with: `pip install skylos[interactive]`
|
|
201
|
+
|
|
202
|
+
## Contributing
|
|
203
|
+
|
|
204
|
+
We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING.md) before submitting pull requests.
|
|
205
|
+
|
|
206
|
+
### Quick Contribution Guide
|
|
207
|
+
|
|
208
|
+
1. Fork the repository
|
|
209
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
210
|
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
211
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
212
|
+
5. Open a Pull Request
|
|
213
|
+
|
|
214
|
+
## Roadmap
|
|
215
|
+
|
|
216
|
+
- [ ] Expand our test cases
|
|
217
|
+
- [ ] Configuration file support
|
|
218
|
+
- [ ] Custom analysis rules
|
|
219
|
+
- [ ] Git hooks integration
|
|
220
|
+
- [ ] CI/CD integration examples
|
|
221
|
+
- [ ] Web interface
|
|
222
|
+
- [ ] Support for other languages
|
|
223
|
+
- [ ] Further optimization
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.
|
|
228
|
+
|
|
229
|
+
## Contact
|
|
230
|
+
|
|
231
|
+
- **Author**: oha
|
|
232
|
+
- **Email**: aaronoh2015@gmail.com
|
|
233
|
+
- **GitHub**: [@duriantaco](https://github.com/duriantaco)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "skylos"
|
|
7
|
+
version = "1.0.8"
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
description = "A static analysis tool for Python codebases"
|
|
10
|
+
authors = [{name = "oha", email = "aaronoh2015@gmail.com"}]
|
|
11
|
+
dependencies = ["inquirer>=3.0.0"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
skylos = "skylos.cli:main"
|
skylos-1.0.8/setup.cfg
ADDED
skylos-1.0.8/setup.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="skylos",
|
|
5
|
+
version="1.0.8",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
python_requires=">=3.9",
|
|
8
|
+
install_requires=["inquirer>=3.0.0"],
|
|
9
|
+
classifiers=[
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: Apache 2.0 License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.9",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
],
|
|
21
|
+
entry_points={
|
|
22
|
+
"console_scripts": [
|
|
23
|
+
"skylos=skylos.cli:main",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
)
|
|
@@ -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]")
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import ast,re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
PYTHON_BUILTINS={"print","len","str","int","float","list","dict","set","tuple","range","open","super","object","type","enumerate","zip","map","filter","sorted","reversed","sum","min","max","all","any","next","iter","repr","chr","ord","bytes","bytearray","memoryview","format","round","abs","pow","divmod","complex","hash","id","bool","callable","getattr","setattr","delattr","hasattr","isinstance","issubclass","globals","locals","vars","dir","property","classmethod","staticmethod"}
|
|
6
|
+
DYNAMIC_PATTERNS={"getattr","globals","eval","exec"}
|
|
7
|
+
|
|
8
|
+
class Definition:
|
|
9
|
+
__slots__ = ('name', 'type', 'filename', 'line', 'simple_name', 'confidence', 'references', 'is_exported', 'in_init')
|
|
10
|
+
|
|
11
|
+
def __init__(self, n, t, f, l):
|
|
12
|
+
self.name = n
|
|
13
|
+
self.type = t
|
|
14
|
+
self.filename = f
|
|
15
|
+
self.line = l
|
|
16
|
+
self.simple_name = n.split('.')[-1]
|
|
17
|
+
self.confidence = 100
|
|
18
|
+
self.references = 0
|
|
19
|
+
self.is_exported = False
|
|
20
|
+
self.in_init = "__init__.py" in str(f)
|
|
21
|
+
|
|
22
|
+
def to_dict(self):
|
|
23
|
+
if self.type == "method" and "." in self.name:
|
|
24
|
+
parts = self.name.split(".")
|
|
25
|
+
if len(parts) >= 3:
|
|
26
|
+
output_name = ".".join(parts[-2:])
|
|
27
|
+
else:
|
|
28
|
+
output_name = self.name
|
|
29
|
+
else:
|
|
30
|
+
output_name = self.simple_name
|
|
31
|
+
|
|
32
|
+
return{
|
|
33
|
+
"name": output_name,
|
|
34
|
+
"full_name": self.name,
|
|
35
|
+
"simple_name": self.simple_name,
|
|
36
|
+
"type": self.type,
|
|
37
|
+
"file": str(self.filename),
|
|
38
|
+
"basename": Path(self.filename).name,
|
|
39
|
+
"line": self.line,
|
|
40
|
+
"confidence": self.confidence,
|
|
41
|
+
"references": self.references
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class Visitor(ast.NodeVisitor):
|
|
45
|
+
def __init__(self,mod,file):
|
|
46
|
+
self.mod=mod
|
|
47
|
+
self.file=file
|
|
48
|
+
self.defs=[]
|
|
49
|
+
self.refs=[]
|
|
50
|
+
self.cls=None
|
|
51
|
+
self.alias={}
|
|
52
|
+
self.dyn=set()
|
|
53
|
+
self.exports=set()
|
|
54
|
+
self.current_function_scope = []
|
|
55
|
+
|
|
56
|
+
def add_def(self,n,t,l):
|
|
57
|
+
if n not in{d.name for d in self.defs}:self.defs.append(Definition(n,t,self.file,l))
|
|
58
|
+
|
|
59
|
+
def add_ref(self,n):self.refs.append((n,self.file))
|
|
60
|
+
|
|
61
|
+
def qual(self,n):
|
|
62
|
+
if n in self.alias:return self.alias[n]
|
|
63
|
+
if n in PYTHON_BUILTINS:return n
|
|
64
|
+
return f"{self.mod}.{n}"if self.mod else n
|
|
65
|
+
|
|
66
|
+
def visit_Import(self,node):
|
|
67
|
+
for a in node.names:
|
|
68
|
+
full=a.name
|
|
69
|
+
self.alias[a.asname or a.name.split(".")[-1]]=full
|
|
70
|
+
self.add_def(full,"import",node.lineno)
|
|
71
|
+
|
|
72
|
+
def visit_ImportFrom(self,node):
|
|
73
|
+
if node.module is None:return
|
|
74
|
+
for a in node.names:
|
|
75
|
+
if a.name=="*":continue
|
|
76
|
+
base=node.module
|
|
77
|
+
if node.level:
|
|
78
|
+
parts=self.mod.split(".")
|
|
79
|
+
base=".".join(parts[:-node.level])+(f".{node.module}"if node.module else"")
|
|
80
|
+
full=f"{base}.{a.name}"
|
|
81
|
+
self.alias[a.asname or a.name]=full
|
|
82
|
+
self.add_def(full,"import",node.lineno)
|
|
83
|
+
|
|
84
|
+
def visit_FunctionDef(self,node):
|
|
85
|
+
outer_scope_prefix = '.'.join(self.current_function_scope) + '.' if self.current_function_scope else ''
|
|
86
|
+
|
|
87
|
+
if self.cls:
|
|
88
|
+
name_parts = [self.mod, self.cls, outer_scope_prefix + node.name]
|
|
89
|
+
else:
|
|
90
|
+
name_parts = [self.mod, outer_scope_prefix + node.name]
|
|
91
|
+
|
|
92
|
+
qualified_name = ".".join(filter(None, name_parts))
|
|
93
|
+
|
|
94
|
+
self.add_def(qualified_name,"method"if self.cls else"function",node.lineno)
|
|
95
|
+
|
|
96
|
+
self.current_function_scope.append(node.name)
|
|
97
|
+
for d_node in node.decorator_list:
|
|
98
|
+
self.visit(d_node)
|
|
99
|
+
for stmt in node.body:
|
|
100
|
+
self.visit(stmt)
|
|
101
|
+
self.current_function_scope.pop()
|
|
102
|
+
|
|
103
|
+
visit_AsyncFunctionDef=visit_FunctionDef
|
|
104
|
+
|
|
105
|
+
def visit_ClassDef(self,node):
|
|
106
|
+
cname=f"{self.mod}.{node.name}"
|
|
107
|
+
self.add_def(cname,"class",node.lineno)
|
|
108
|
+
prev=self.cls;self.cls=node.name
|
|
109
|
+
for b in node.body:self.visit(b)
|
|
110
|
+
self.cls=prev
|
|
111
|
+
|
|
112
|
+
def visit_Assign(self, node):
|
|
113
|
+
for target in node.targets:
|
|
114
|
+
if isinstance(target, ast.Name) and target.id == "__all__":
|
|
115
|
+
if isinstance(node.value, (ast.List, ast.Tuple)):
|
|
116
|
+
for elt in node.value.elts:
|
|
117
|
+
value = None
|
|
118
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
119
|
+
value = elt.value
|
|
120
|
+
elif hasattr(elt, 's') and isinstance(elt.s, str):
|
|
121
|
+
value = elt.s
|
|
122
|
+
|
|
123
|
+
if value is not None:
|
|
124
|
+
full_name = f"{self.mod}.{value}"
|
|
125
|
+
self.add_ref(full_name)
|
|
126
|
+
self.add_ref(value)
|
|
127
|
+
self.generic_visit(node)
|
|
128
|
+
|
|
129
|
+
def visit_Call(self, node):
|
|
130
|
+
self.generic_visit(node)
|
|
131
|
+
|
|
132
|
+
if isinstance(node.func, ast.Name) and node.func.id in ("getattr", "hasattr") and len(node.args) >= 2:
|
|
133
|
+
if isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
|
|
134
|
+
attr_name = node.args[1].value
|
|
135
|
+
self.add_ref(attr_name)
|
|
136
|
+
|
|
137
|
+
if isinstance(node.args[0], ast.Name):
|
|
138
|
+
module_name = node.args[0].id
|
|
139
|
+
if module_name != "self":
|
|
140
|
+
qualified_name = f"{self.qual(module_name)}.{attr_name}"
|
|
141
|
+
self.add_ref(qualified_name)
|
|
142
|
+
|
|
143
|
+
elif isinstance(node.func, ast.Name) and node.func.id == "globals":
|
|
144
|
+
parent = getattr(node, 'parent', None)
|
|
145
|
+
if (isinstance(parent, ast.Subscript) and
|
|
146
|
+
isinstance(parent.slice, ast.Constant) and
|
|
147
|
+
isinstance(parent.slice.value, str)):
|
|
148
|
+
func_name = parent.slice.value
|
|
149
|
+
self.add_ref(func_name)
|
|
150
|
+
self.add_ref(f"{self.mod}.{func_name}")
|
|
151
|
+
|
|
152
|
+
elif (isinstance(node.func, ast.Attribute) and
|
|
153
|
+
node.func.attr == "format" and
|
|
154
|
+
isinstance(node.func.value, ast.Constant) and
|
|
155
|
+
isinstance(node.func.value.value, str)):
|
|
156
|
+
fmt = node.func.value.value
|
|
157
|
+
if any(isinstance(k.arg, str) and k.arg is None for k in node.keywords):
|
|
158
|
+
for _, n, _, _ in re.findall(r'\{([^}:!]+)', fmt):
|
|
159
|
+
if n:
|
|
160
|
+
self.add_ref(self.qual(n))
|
|
161
|
+
|
|
162
|
+
def visit_Name(self,node):
|
|
163
|
+
if isinstance(node.ctx,ast.Load):
|
|
164
|
+
self.add_ref(self.qual(node.id))
|
|
165
|
+
if node.id in DYNAMIC_PATTERNS:self.dyn.add(self.mod.split(".")[0])
|
|
166
|
+
|
|
167
|
+
def visit_Attribute(self,node):
|
|
168
|
+
self.generic_visit(node)
|
|
169
|
+
if isinstance(node.ctx,ast.Load)and isinstance(node.value,ast.Name):
|
|
170
|
+
self.add_ref(f"{self.qual(node.value.id)}.{node.attr}")
|
|
171
|
+
|
|
172
|
+
def generic_visit(self, node):
|
|
173
|
+
for field, value in ast.iter_fields(node):
|
|
174
|
+
if isinstance(value, list):
|
|
175
|
+
for item in value:
|
|
176
|
+
if isinstance(item, ast.AST):
|
|
177
|
+
item.parent = node
|
|
178
|
+
self.visit(item)
|
|
179
|
+
elif isinstance(value, ast.AST):
|
|
180
|
+
value.parent = node
|
|
181
|
+
self.visit(value)
|