skylos 1.1.12__tar.gz → 1.2.1__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.1.12 → skylos-1.2.1}/PKG-INFO +1 -1
- {skylos-1.1.12 → skylos-1.2.1}/README.md +103 -7
- {skylos-1.1.12 → skylos-1.2.1}/pyproject.toml +1 -1
- {skylos-1.1.12 → skylos-1.2.1}/setup.py +1 -1
- {skylos-1.1.12 → skylos-1.2.1}/skylos/analyzer.py +128 -133
- {skylos-1.1.12 → skylos-1.2.1}/skylos/cli.py +20 -2
- skylos-1.2.1/skylos/constants.py +42 -0
- skylos-1.2.1/skylos/framework_aware.py +158 -0
- skylos-1.2.1/skylos/test_aware.py +66 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos/visitor.py +28 -3
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/PKG-INFO +1 -1
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/SOURCES.txt +6 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/test_analyzer.py +253 -195
- skylos-1.2.1/test/test_changes_analyzer.py +149 -0
- skylos-1.2.1/test/test_constants.py +348 -0
- skylos-1.2.1/test/test_framework_aware.py +372 -0
- skylos-1.2.1/test/test_test_aware.py +328 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/test_visitor.py +0 -10
- skylos-1.1.12/skylos/constants.py +0 -35
- {skylos-1.1.12 → skylos-1.2.1}/setup.cfg +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos/__init__.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/dependency_links.txt +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/entry_points.txt +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/requires.txt +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/top_level.txt +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/__init__.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/compare_tools.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/conftest.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/diagnostics.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/__init__.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/app.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/__init__.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/commands.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/models.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/routes.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/utils.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/test_cli.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/test_integration.py +0 -0
- {skylos-1.1.12 → skylos-1.2.1}/test/test_skylos.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|

|
|
4
4
|

|
|
5
5
|

|
|
6
|
+

|
|
6
7
|
|
|
7
8
|
<div align="center">
|
|
8
9
|
<img src="assets/SKYLOS.png" alt="Skylos Logo" width="200">
|
|
@@ -10,12 +11,36 @@
|
|
|
10
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
13
|
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Features](#features)
|
|
17
|
+
- [Benchmark](#benchmark-you-can-find-this-benchmark-test-in-test-folder)
|
|
18
|
+
- [Installation](#installation)
|
|
19
|
+
- [Quick Start](#quick-start)
|
|
20
|
+
- [Understanding Confidence Levels](#understanding-confidence-levels)
|
|
21
|
+
- [Test File Detection](#test-file-detection)
|
|
22
|
+
- [Folder Management](#folder-management)
|
|
23
|
+
- [CLI Options](#cli-options)
|
|
24
|
+
- [Example Output](#example-output)
|
|
25
|
+
- [Interactive Mode](#interactive-mode)
|
|
26
|
+
- [Development](#development)
|
|
27
|
+
- [FAQ](#faq)
|
|
28
|
+
- [Limitations](#limitations)
|
|
29
|
+
- [Troubleshooting](#troubleshooting)
|
|
30
|
+
- [Contributing](#contributing)
|
|
31
|
+
- [Roadmap](#roadmap)
|
|
32
|
+
- [License](#license)
|
|
33
|
+
- [Contact](#contact)
|
|
34
|
+
|
|
13
35
|
## Features
|
|
14
36
|
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
37
|
+
* **Framework-Aware Detection**: Attempt at handling Flask, Django, FastAPI routes and decorators
|
|
38
|
+
* **Test File Exclusion**: Auto excludes test files (you can include it back if you want)
|
|
39
|
+
* **Interactive Cleanup**: Select specific items to remove from CLI
|
|
40
|
+
* **Unused Functions & Methods**: Finds functions and methods that not called
|
|
41
|
+
* **Unused Classes**: Detects classes that are not instantiated or inherited
|
|
42
|
+
* **Unused Imports**: Identifies imports that are not used
|
|
43
|
+
* **Folder Management**: Inclusion/exclusion of directories
|
|
19
44
|
|
|
20
45
|
## Benchmark (You can find this benchmark test in `test` folder)
|
|
21
46
|
|
|
@@ -70,9 +95,74 @@ skylos --interactive --dry-run /path/to/your/project
|
|
|
70
95
|
|
|
71
96
|
# Output to JSON
|
|
72
97
|
skylos --json /path/to/your/project
|
|
98
|
+
|
|
99
|
+
# With confidence
|
|
100
|
+
skylos path/to/your/file --confidence 20 ## or whatever value u wanna set
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Understanding Confidence Levels
|
|
104
|
+
|
|
105
|
+
Skylos uses a confidence-based system to try to handle Python's dynamic nature and web frameworks.
|
|
106
|
+
|
|
107
|
+
### How Confidence Works
|
|
108
|
+
|
|
109
|
+
- **Confidence 100**: 100% unused (default imports, obvious dead functions)
|
|
110
|
+
- **Confidence 60**: Default value - conservative detection
|
|
111
|
+
- **Confidence 40**: Framework helpers, functions in web app files
|
|
112
|
+
- **Confidence 20**: Framework routes, decorated functions
|
|
113
|
+
- **Confidence 0**: Show everything
|
|
114
|
+
|
|
115
|
+
### Framework Detection
|
|
116
|
+
|
|
117
|
+
When Skylos detects web framework imports (Flask, Django, FastAPI), it applies different confidence levels:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# only obvious dead codes
|
|
121
|
+
skylos app.py --confidence 60 # THIS IS THE DEFAULT
|
|
122
|
+
|
|
123
|
+
# include unused helpers in framework files
|
|
124
|
+
skylos app.py --confidence 30
|
|
125
|
+
|
|
126
|
+
# include potentially unused routes
|
|
127
|
+
skylos app.py --confidence 20
|
|
128
|
+
|
|
129
|
+
# everything.. shows how all potential dead code
|
|
130
|
+
skylos app.py --confidence 0
|
|
73
131
|
```
|
|
74
132
|
|
|
75
|
-
##
|
|
133
|
+
## Test File Detection
|
|
134
|
+
|
|
135
|
+
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)
|
|
136
|
+
|
|
137
|
+
### What Gets Detected as Test Files
|
|
138
|
+
|
|
139
|
+
**File paths containing:**
|
|
140
|
+
- `/test/` or `/tests/` directories
|
|
141
|
+
- Files ending with `_test.py`
|
|
142
|
+
|
|
143
|
+
**Test imports:**
|
|
144
|
+
- `pytest`, `unittest`, `nose`, `mock`, `responses`
|
|
145
|
+
|
|
146
|
+
**Test decorators:**
|
|
147
|
+
- `@pytest.fixture`, `@pytest.mark.*`
|
|
148
|
+
- `@patch`, `@mock.*`
|
|
149
|
+
|
|
150
|
+
### Test File Behavior
|
|
151
|
+
|
|
152
|
+
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
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# This will show 0 dead code because its treated as test file
|
|
156
|
+
/project/tests/test_user.py
|
|
157
|
+
/project/test/helper.py
|
|
158
|
+
/project/utils_test.py
|
|
159
|
+
|
|
160
|
+
# The files will be analyzed normally. Note that it doesn't end with _test.py
|
|
161
|
+
/project/user.py
|
|
162
|
+
/project/test_data.py
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Including & Excluding Files
|
|
76
166
|
|
|
77
167
|
### Default Exclusions
|
|
78
168
|
By default, Skylos excludes common folders: `__pycache__`, `.git`, `.pytest_cache`, `.mypy_cache`, `.tox`, `htmlcov`, `.coverage`, `build`, `dist`, `*.egg-info`, `venv`, `.venv`
|
|
@@ -113,6 +203,7 @@ Options:
|
|
|
113
203
|
--include-folder FOLDER Force include a folder that would otherwise be excluded
|
|
114
204
|
--no-default-excludes Don't exclude default folders (__pycache__, .git, venv, etc.)
|
|
115
205
|
--list-default-excludes List the default excluded folders and
|
|
206
|
+
-c, --confidence LEVEL Confidence threshold (0-100). Lower values will show more items.
|
|
116
207
|
```
|
|
117
208
|
|
|
118
209
|
## Example Output
|
|
@@ -204,6 +295,12 @@ A: No. Always review results manually, especially for framework code, APIs, and
|
|
|
204
295
|
**Q: Why did Ruff underperform?**
|
|
205
296
|
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.
|
|
206
297
|
|
|
298
|
+
**Q: Why doesn't Skylos detect my unused Flask routes?**
|
|
299
|
+
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.
|
|
300
|
+
|
|
301
|
+
**Q: What confidence level should I use?**
|
|
302
|
+
A: Start with 60 (default) for safe cleanup. Use 30 for framework applications. Use 20 for more comprehensive auditing.
|
|
303
|
+
|
|
207
304
|
## Limitations
|
|
208
305
|
|
|
209
306
|
- **Dynamic code**: `getattr()`, `globals()`, runtime imports are hard to detect
|
|
@@ -211,7 +308,7 @@ A: Like all other tools, Ruff is focused on detecting specific, surface-level is
|
|
|
211
308
|
- **Test data**: Limited scenarios, your mileage may vary
|
|
212
309
|
- **False positives**: Always manually review before deleting code
|
|
213
310
|
|
|
214
|
-
If we can detect 100% of all dead code in any structure, we wouldn't be sitting here. But we definitely tried our best
|
|
311
|
+
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.
|
|
215
312
|
|
|
216
313
|
## Troubleshooting
|
|
217
314
|
|
|
@@ -242,7 +339,6 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
|
|
|
242
339
|
5. Open a Pull Request
|
|
243
340
|
|
|
244
341
|
## Roadmap
|
|
245
|
-
- [ ] Add a production flag, to include dead codes that are used in test but not in the actual execution
|
|
246
342
|
- [x] Expand our test cases
|
|
247
343
|
- [ ] Configuration file support
|
|
248
344
|
- [ ] Git hooks integration
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
import ast
|
|
2
|
+
import ast
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
3
6
|
from pathlib import Path
|
|
4
7
|
from collections import defaultdict
|
|
5
8
|
from skylos.visitor import Visitor
|
|
6
|
-
from skylos.constants import (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
import io
|
|
15
|
+
import tokenize
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
11
18
|
|
|
12
19
|
logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(message)s')
|
|
13
20
|
logger=logging.getLogger('Skylos')
|
|
@@ -27,12 +34,27 @@ def parse_exclude_folders(user_exclude_folders, use_defaults=True, include_folde
|
|
|
27
34
|
|
|
28
35
|
return exclude_set
|
|
29
36
|
|
|
37
|
+
IGNORE_PATTERNS = (
|
|
38
|
+
r"#\s*pragma:\s*no\s+skylos", ## our own pragma
|
|
39
|
+
r"#\s*pragma:\s*no\s+cover",
|
|
40
|
+
r"#\s*noqa(?:\b|:)", # flake8 style
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _collect_ignored_lines(source: str) -> set[int]:
|
|
44
|
+
ignores = set()
|
|
45
|
+
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
46
|
+
if tok.type == tokenize.COMMENT:
|
|
47
|
+
if any(re.search(pat, tok.string, flags=re.I) for pat in IGNORE_PATTERNS):
|
|
48
|
+
ignores.add(tok.start[0])
|
|
49
|
+
return ignores
|
|
50
|
+
|
|
30
51
|
class Skylos:
|
|
31
52
|
def __init__(self):
|
|
32
53
|
self.defs={}
|
|
33
54
|
self.refs=[]
|
|
34
55
|
self.dynamic=set()
|
|
35
56
|
self.exports=defaultdict(set)
|
|
57
|
+
self.ignored_lines:set[int]=set()
|
|
36
58
|
|
|
37
59
|
def _module(self,root,f):
|
|
38
60
|
p=list(f.relative_to(root).parts)
|
|
@@ -131,6 +153,12 @@ class Skylos:
|
|
|
131
153
|
matches = simple_name_lookup.get(simple, [])
|
|
132
154
|
for d in matches:
|
|
133
155
|
d.references += 1
|
|
156
|
+
|
|
157
|
+
for module_name in self.dynamic:
|
|
158
|
+
for def_name, def_obj in self.defs.items():
|
|
159
|
+
if def_obj.name.startswith(f"{module_name}."):
|
|
160
|
+
if def_obj.type in ("function", "method") and not def_obj.simple_name.startswith("_"):
|
|
161
|
+
def_obj.references += 1
|
|
134
162
|
|
|
135
163
|
def _get_base_classes(self, class_name):
|
|
136
164
|
if class_name not in self.defs:
|
|
@@ -143,128 +171,62 @@ class Skylos:
|
|
|
143
171
|
|
|
144
172
|
return []
|
|
145
173
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if
|
|
164
|
-
|
|
165
|
-
"test_" in file_str or
|
|
166
|
-
"/test/" in file_str or
|
|
167
|
-
"/tests/" in file_str or
|
|
168
|
-
"\\test\\" in file_str or
|
|
169
|
-
"\\tests\\" in file_str):
|
|
170
|
-
return True
|
|
171
|
-
|
|
172
|
-
return False
|
|
174
|
+
def _apply_penalties(self, def_obj, visitor, framework):
|
|
175
|
+
c = 100
|
|
176
|
+
|
|
177
|
+
if def_obj.simple_name.startswith("_") and not def_obj.simple_name.startswith("__"):
|
|
178
|
+
c -= PENALTIES["private_name"]
|
|
179
|
+
if def_obj.simple_name.startswith("__") and def_obj.simple_name.endswith("__"):
|
|
180
|
+
c -= PENALTIES["dunder_or_magic"]
|
|
181
|
+
if def_obj.type == "variable" and def_obj.simple_name == "_":
|
|
182
|
+
c -= PENALTIES["underscored_var"]
|
|
183
|
+
if def_obj.in_init and def_obj.type in ("function", "class"):
|
|
184
|
+
c -= PENALTIES["in_init_file"]
|
|
185
|
+
if def_obj.name.split(".")[0] in self.dynamic:
|
|
186
|
+
c -= PENALTIES["dynamic_module"]
|
|
187
|
+
if visitor.is_test_file or def_obj.line in visitor.test_decorated_lines:
|
|
188
|
+
c -= PENALTIES["test_related"]
|
|
189
|
+
|
|
190
|
+
framework_confidence = detect_framework_usage(def_obj, visitor=framework)
|
|
191
|
+
if framework_confidence is not None:
|
|
192
|
+
c = min(c, framework_confidence)
|
|
173
193
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
if (def_obj.simple_name.startswith("__") and def_obj.simple_name.endswith("__")):
|
|
195
|
+
c = 0
|
|
196
|
+
|
|
197
|
+
if def_obj.type == "parameter":
|
|
198
|
+
if def_obj.simple_name in ("self", "cls"):
|
|
199
|
+
c = 0
|
|
200
|
+
elif "." in def_obj.name:
|
|
201
|
+
method_name = def_obj.name.split(".")[-2]
|
|
202
|
+
if method_name.startswith("__") and method_name.endswith("__"):
|
|
203
|
+
c = 0
|
|
204
|
+
|
|
205
|
+
if visitor.is_test_file or def_obj.line in visitor.test_decorated_lines:
|
|
206
|
+
c = 0
|
|
207
|
+
|
|
208
|
+
if (def_obj.type == "import" and def_obj.name.startswith("__future__.") and
|
|
209
|
+
def_obj.simple_name in ("annotations", "absolute_import", "division",
|
|
210
|
+
"print_function", "unicode_literals", "generator_stop")):
|
|
211
|
+
c = 0
|
|
189
212
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
class_simple_name = class_name.split(".")[-1]
|
|
193
|
-
|
|
194
|
-
if (class_simple_name.startswith("Test") or
|
|
195
|
-
class_simple_name.endswith("Test") or
|
|
196
|
-
class_simple_name.endswith("TestCase")):
|
|
197
|
-
return True
|
|
198
|
-
|
|
199
|
-
if (definition.type == "method" and
|
|
200
|
-
(TEST_METHOD_PATTERN.match(definition.simple_name) or
|
|
201
|
-
definition.simple_name in TEST_LIFECYCLE_METHODS)):
|
|
202
|
-
return True
|
|
203
|
-
|
|
204
|
-
# NOT for imports, variables, parameters
|
|
205
|
-
if definition.type in ("function", "method", "class"):
|
|
206
|
-
if self._is_test_file(definition.filename):
|
|
207
|
-
return True
|
|
208
|
-
|
|
209
|
-
if self._has_test_imports(definition.filename):
|
|
210
|
-
return True
|
|
211
|
-
|
|
212
|
-
## check decorators -- test related
|
|
213
|
-
if self._has_test_decorators(definition.filename):
|
|
214
|
-
return True
|
|
215
|
-
|
|
216
|
-
return False
|
|
217
|
-
|
|
213
|
+
def_obj.confidence = max(c, 0)
|
|
214
|
+
|
|
218
215
|
def _apply_heuristics(self):
|
|
219
|
-
class_methods=defaultdict(list)
|
|
216
|
+
class_methods = defaultdict(list)
|
|
220
217
|
for d in self.defs.values():
|
|
221
|
-
if d.type in("method","function") and"." in d.name:
|
|
222
|
-
cls=d.name.rsplit(".",1)[0]
|
|
223
|
-
if cls in self.defs and self.defs[cls].type=="class":
|
|
218
|
+
if d.type in ("method", "function") and "." in d.name:
|
|
219
|
+
cls = d.name.rsplit(".", 1)[0]
|
|
220
|
+
if cls in self.defs and self.defs[cls].type == "class":
|
|
224
221
|
class_methods[cls].append(d)
|
|
225
|
-
|
|
226
|
-
for cls,methods in class_methods.items():
|
|
227
|
-
if self.defs[cls].references>0:
|
|
222
|
+
|
|
223
|
+
for cls, methods in class_methods.items():
|
|
224
|
+
if self.defs[cls].references > 0:
|
|
228
225
|
for m in methods:
|
|
229
|
-
if m.simple_name in AUTO_CALLED:
|
|
230
|
-
|
|
231
|
-
for d in self.defs.values():
|
|
232
|
-
if d.simple_name in MAGIC_METHODS or (d.simple_name.startswith("__") and d.simple_name.endswith("__")):
|
|
233
|
-
d.confidence = 0
|
|
234
|
-
|
|
235
|
-
if d.type == "parameter" and d.simple_name in ("self", "cls"):
|
|
236
|
-
d.confidence = 0
|
|
237
|
-
|
|
238
|
-
if d.type != "parameter" and (d.simple_name in MAGIC_METHODS or (d.simple_name.startswith("__") and d.simple_name.endswith("__"))):
|
|
239
|
-
d.confidence = 0
|
|
240
|
-
|
|
241
|
-
if (d.type == "import" and d.name.startswith("__future__.") and
|
|
242
|
-
d.simple_name in ("annotations", "absolute_import", "division",
|
|
243
|
-
"print_function", "unicode_literals", "generator_stop")):
|
|
244
|
-
d.confidence = 0
|
|
245
|
-
|
|
246
|
-
if (d.simple_name.startswith("_") and
|
|
247
|
-
not d.simple_name.startswith("__") and
|
|
248
|
-
d.simple_name != "_"):
|
|
249
|
-
d.confidence = 0
|
|
250
|
-
|
|
251
|
-
if not d.simple_name.startswith("_") and d.type in ("function", "method", "class"):
|
|
252
|
-
d.confidence = min(d.confidence, 90)
|
|
253
|
-
|
|
254
|
-
if d.in_init and d.type in ("function", "class"):
|
|
255
|
-
d.confidence = min(d.confidence, 85)
|
|
256
|
-
|
|
257
|
-
if d.name.split(".")[0] in self.dynamic:
|
|
258
|
-
d.confidence = min(d.confidence, 60)
|
|
259
|
-
|
|
260
|
-
if d.type == "variable" and d.simple_name == "_":
|
|
261
|
-
d.confidence = 0
|
|
262
|
-
|
|
263
|
-
if self._is_test_related(d):
|
|
264
|
-
d.confidence = 0
|
|
226
|
+
if m.simple_name in AUTO_CALLED: # __init__, __enter__, __exit__
|
|
227
|
+
m.references += 1
|
|
265
228
|
|
|
266
229
|
def analyze(self, path, thr=60, exclude_folders=None):
|
|
267
|
-
|
|
268
230
|
files, root = self._get_python_files(path, exclude_folders)
|
|
269
231
|
|
|
270
232
|
if not files:
|
|
@@ -289,25 +251,43 @@ class Skylos:
|
|
|
289
251
|
|
|
290
252
|
for file in files:
|
|
291
253
|
mod = modmap[file]
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
254
|
+
|
|
255
|
+
result = proc_file(file, mod)
|
|
256
|
+
|
|
257
|
+
if len(result) == 7: ##new
|
|
258
|
+
defs, refs, dyn, exports, test_flags, framework_flags, ignored = result
|
|
259
|
+
self.ignored_lines.update(ignored)
|
|
260
|
+
else: ##legacy
|
|
261
|
+
warnings.warn(
|
|
262
|
+
"proc_file() now returns 7 values (added ignored_lines). "
|
|
263
|
+
"The 6-value form is deprecated and will disappear.",
|
|
264
|
+
DeprecationWarning,
|
|
265
|
+
stacklevel=2,
|
|
266
|
+
)
|
|
267
|
+
defs, refs, dyn, exports, test_flags, framework_flags = result
|
|
268
|
+
|
|
269
|
+
# apply penalties while we still have the file-specific flags
|
|
270
|
+
for d in defs:
|
|
271
|
+
self._apply_penalties(d, test_flags, framework_flags)
|
|
295
272
|
self.defs[d.name] = d
|
|
273
|
+
|
|
296
274
|
self.refs.extend(refs)
|
|
297
275
|
self.dynamic.update(dyn)
|
|
298
276
|
self.exports[mod].update(exports)
|
|
299
|
-
|
|
277
|
+
|
|
300
278
|
self._mark_refs()
|
|
301
|
-
self._apply_heuristics()
|
|
279
|
+
self._apply_heuristics()
|
|
302
280
|
self._mark_exports()
|
|
303
|
-
|
|
281
|
+
|
|
304
282
|
thr = max(0, thr)
|
|
305
283
|
|
|
306
284
|
unused = []
|
|
307
285
|
for d in self.defs.values():
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
286
|
+
if (d.references == 0 and not d.is_exported
|
|
287
|
+
and d.confidence >= thr
|
|
288
|
+
and d.line not in self.ignored_lines):
|
|
289
|
+
unused.append(d.to_dict())
|
|
290
|
+
|
|
311
291
|
result = {
|
|
312
292
|
"unused_functions": [],
|
|
313
293
|
"unused_imports": [],
|
|
@@ -341,13 +321,28 @@ def proc_file(file_or_args, mod=None):
|
|
|
341
321
|
file = file_or_args
|
|
342
322
|
|
|
343
323
|
try:
|
|
344
|
-
|
|
345
|
-
|
|
324
|
+
source = Path(file).read_text(encoding="utf-8")
|
|
325
|
+
ignored = _collect_ignored_lines(source)
|
|
326
|
+
tree = ast.parse(source)
|
|
327
|
+
|
|
328
|
+
tv = TestAwareVisitor(filename=file)
|
|
329
|
+
tv.visit(tree)
|
|
330
|
+
|
|
331
|
+
fv = FrameworkAwareVisitor(filename=file)
|
|
332
|
+
fv.visit(tree)
|
|
333
|
+
|
|
334
|
+
v = Visitor(mod, file)
|
|
346
335
|
v.visit(tree)
|
|
347
|
-
|
|
336
|
+
|
|
337
|
+
return v.defs, v.refs, v.dyn, v.exports, tv, fv, ignored
|
|
348
338
|
except Exception as e:
|
|
349
339
|
logger.error(f"{file}: {e}")
|
|
350
|
-
|
|
340
|
+
if os.getenv("SKYLOS_DEBUG"):
|
|
341
|
+
logger.error(traceback.format_exc())
|
|
342
|
+
dummy_visitor = TestAwareVisitor(filename=file)
|
|
343
|
+
dummy_framework_visitor = FrameworkAwareVisitor(filename=file)
|
|
344
|
+
|
|
345
|
+
return [], [], set(), set(), dummy_visitor, dummy_framework_visitor, set()
|
|
351
346
|
|
|
352
347
|
def analyze(path,conf=60, exclude_folders=None):
|
|
353
348
|
return Skylos().analyze(path,conf, exclude_folders)
|
|
@@ -218,6 +218,13 @@ def main() -> None:
|
|
|
218
218
|
action="store_true",
|
|
219
219
|
help="Enable verbose output"
|
|
220
220
|
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--confidence",
|
|
223
|
+
"-c",
|
|
224
|
+
type=int,
|
|
225
|
+
default=60,
|
|
226
|
+
help="Confidence threshold (0-100). Lower values include more items. Default: 60"
|
|
227
|
+
)
|
|
221
228
|
parser.add_argument(
|
|
222
229
|
"--interactive", "-i",
|
|
223
230
|
action="store_true",
|
|
@@ -293,7 +300,7 @@ def main() -> None:
|
|
|
293
300
|
logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
|
|
294
301
|
|
|
295
302
|
try:
|
|
296
|
-
result_json = skylos.analyze(args.path, exclude_folders=list(final_exclude_folders))
|
|
303
|
+
result_json = skylos.analyze(args.path, conf=args.confidence, exclude_folders=list(final_exclude_folders))
|
|
297
304
|
result = json.loads(result_json)
|
|
298
305
|
|
|
299
306
|
except Exception as e:
|
|
@@ -308,6 +315,7 @@ def main() -> None:
|
|
|
308
315
|
unused_imports = result.get("unused_imports", [])
|
|
309
316
|
unused_parameters = result.get("unused_parameters", [])
|
|
310
317
|
unused_variables = result.get("unused_variables", [])
|
|
318
|
+
unused_classes = result.get("unused_classes", [])
|
|
311
319
|
|
|
312
320
|
logger.info(f"{Colors.CYAN}{Colors.BOLD}🔍 Python Static Analysis Results{Colors.RESET}")
|
|
313
321
|
logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
|
|
@@ -317,6 +325,7 @@ def main() -> None:
|
|
|
317
325
|
logger.info(f" • Unused imports: {Colors.YELLOW}{len(unused_imports)}{Colors.RESET}")
|
|
318
326
|
logger.info(f" • Unused parameters: {Colors.YELLOW}{len(unused_parameters)}{Colors.RESET}")
|
|
319
327
|
logger.info(f" • Unused variables: {Colors.YELLOW}{len(unused_variables)}{Colors.RESET}")
|
|
328
|
+
logger.info(f" • Unused classes: {Colors.YELLOW}{len(unused_classes)}{Colors.RESET}")
|
|
320
329
|
|
|
321
330
|
if args.interactive and (unused_functions or unused_imports):
|
|
322
331
|
logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
|
|
@@ -402,10 +411,19 @@ def main() -> None:
|
|
|
402
411
|
for i, item in enumerate(unused_variables, 1):
|
|
403
412
|
logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.YELLOW}{item['name']}{Colors.RESET}")
|
|
404
413
|
logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
|
|
414
|
+
|
|
415
|
+
if unused_classes:
|
|
416
|
+
logger.info(f"\n{Colors.YELLOW}{Colors.BOLD}📚 Unused Classes{Colors.RESET}")
|
|
417
|
+
logger.info(f"{Colors.YELLOW}{'=' * 18}{Colors.RESET}")
|
|
418
|
+
for i, item in enumerate(unused_classes, 1):
|
|
419
|
+
logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.YELLOW}{item['name']}{Colors.RESET}")
|
|
420
|
+
logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
|
|
421
|
+
|
|
405
422
|
else:
|
|
406
423
|
logger.info(f"\n{Colors.GREEN}✓ All variables are being used!{Colors.RESET}")
|
|
407
424
|
|
|
408
|
-
dead_code_count = len(unused_functions) + len(unused_imports)
|
|
425
|
+
dead_code_count = len(unused_functions) + len(unused_imports) + len(unused_variables) + len(unused_classes) + len(unused_parameters)
|
|
426
|
+
|
|
409
427
|
print_badge(dead_code_count, logger)
|
|
410
428
|
|
|
411
429
|
if unused_functions or unused_imports:
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
PENALTIES = {
|
|
5
|
+
"private_name": 80,
|
|
6
|
+
"dunder_or_magic": 100,
|
|
7
|
+
"underscored_var": 100,
|
|
8
|
+
"in_init_file": 15,
|
|
9
|
+
"dynamic_module": 40,
|
|
10
|
+
"test_related": 100,
|
|
11
|
+
"framework_magic": 40,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
TEST_FILE_RE = re.compile(r"(?:^|[/\\])tests?[/\\]|_test\.py$", re.I)
|
|
15
|
+
TEST_IMPORT_RE = re.compile(r"^(pytest|unittest|nose|mock|responses)(\.|$)")
|
|
16
|
+
TEST_DECOR_RE = re.compile(r"""^(
|
|
17
|
+
pytest\.(fixture|mark) |
|
|
18
|
+
patch(\.|$) |
|
|
19
|
+
responses\.activate |
|
|
20
|
+
freeze_time
|
|
21
|
+
)$""", re.X)
|
|
22
|
+
|
|
23
|
+
AUTO_CALLED = {"__init__", "__enter__", "__exit__"}
|
|
24
|
+
TEST_METHOD_PATTERN = re.compile(r"^test_\w+$")
|
|
25
|
+
|
|
26
|
+
UNITTEST_LIFECYCLE_METHODS = {
|
|
27
|
+
'setUp', 'tearDown', 'setUpClass', 'tearDownClass',
|
|
28
|
+
'setUpModule', 'tearDownModule'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
FRAMEWORK_FILE_RE = re.compile(r"(?:views|handlers|endpoints|routes|api)\.py$", re.I)
|
|
32
|
+
|
|
33
|
+
DEFAULT_EXCLUDE_FOLDERS = {
|
|
34
|
+
"__pycache__", ".git", ".pytest_cache", ".mypy_cache", ".tox",
|
|
35
|
+
"htmlcov", ".coverage", "build", "dist", "*.egg-info", "venv", ".venv"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def is_test_path(p: Path | str) -> bool:
|
|
39
|
+
return bool(TEST_FILE_RE.search(str(p)))
|
|
40
|
+
|
|
41
|
+
def is_framework_path(p: Path | str) -> bool:
|
|
42
|
+
return bool(FRAMEWORK_FILE_RE.search(str(p)))
|