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.

Files changed (39) hide show
  1. {skylos-1.1.12 → skylos-1.2.1}/PKG-INFO +1 -1
  2. {skylos-1.1.12 → skylos-1.2.1}/README.md +103 -7
  3. {skylos-1.1.12 → skylos-1.2.1}/pyproject.toml +1 -1
  4. {skylos-1.1.12 → skylos-1.2.1}/setup.py +1 -1
  5. {skylos-1.1.12 → skylos-1.2.1}/skylos/analyzer.py +128 -133
  6. {skylos-1.1.12 → skylos-1.2.1}/skylos/cli.py +20 -2
  7. skylos-1.2.1/skylos/constants.py +42 -0
  8. skylos-1.2.1/skylos/framework_aware.py +158 -0
  9. skylos-1.2.1/skylos/test_aware.py +66 -0
  10. {skylos-1.1.12 → skylos-1.2.1}/skylos/visitor.py +28 -3
  11. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/PKG-INFO +1 -1
  12. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/SOURCES.txt +6 -0
  13. {skylos-1.1.12 → skylos-1.2.1}/test/test_analyzer.py +253 -195
  14. skylos-1.2.1/test/test_changes_analyzer.py +149 -0
  15. skylos-1.2.1/test/test_constants.py +348 -0
  16. skylos-1.2.1/test/test_framework_aware.py +372 -0
  17. skylos-1.2.1/test/test_test_aware.py +328 -0
  18. {skylos-1.1.12 → skylos-1.2.1}/test/test_visitor.py +0 -10
  19. skylos-1.1.12/skylos/constants.py +0 -35
  20. {skylos-1.1.12 → skylos-1.2.1}/setup.cfg +0 -0
  21. {skylos-1.1.12 → skylos-1.2.1}/skylos/__init__.py +0 -0
  22. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/dependency_links.txt +0 -0
  23. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/entry_points.txt +0 -0
  24. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/requires.txt +0 -0
  25. {skylos-1.1.12 → skylos-1.2.1}/skylos.egg-info/top_level.txt +0 -0
  26. {skylos-1.1.12 → skylos-1.2.1}/test/__init__.py +0 -0
  27. {skylos-1.1.12 → skylos-1.2.1}/test/compare_tools.py +0 -0
  28. {skylos-1.1.12 → skylos-1.2.1}/test/conftest.py +0 -0
  29. {skylos-1.1.12 → skylos-1.2.1}/test/diagnostics.py +0 -0
  30. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/__init__.py +0 -0
  31. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/app.py +0 -0
  32. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/__init__.py +0 -0
  33. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/commands.py +0 -0
  34. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/models.py +0 -0
  35. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/routes.py +0 -0
  36. {skylos-1.1.12 → skylos-1.2.1}/test/sample_repo/sample_repo/utils.py +0 -0
  37. {skylos-1.1.12 → skylos-1.2.1}/test/test_cli.py +0 -0
  38. {skylos-1.1.12 → skylos-1.2.1}/test/test_integration.py +0 -0
  39. {skylos-1.1.12 → skylos-1.2.1}/test/test_skylos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skylos
3
- Version: 1.1.12
3
+ Version: 1.2.1
4
4
  Summary: A static analysis tool for Python codebases
5
5
  Author-email: oha <aaronoh2015@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -3,6 +3,7 @@
3
3
  ![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)
4
4
  ![100% Local](https://img.shields.io/badge/privacy-100%25%20local-brightgreen)
5
5
  ![PyPI version](https://img.shields.io/pypi/v/skylos)
6
+ ![Security Policy](https://img.shields.io/badge/security-policy-brightgreen)
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
- * 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
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
- ## **NEW** Folder Management
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skylos"
7
- version = "1.1.12"
7
+ version = "1.2.1"
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"}]
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="skylos",
5
- version="1.1.12",
5
+ version="1.2.1",
6
6
  packages=find_packages(),
7
7
  python_requires=">=3.9",
8
8
  install_requires=["inquirer>=3.0.0"],
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- import ast,sys,json,logging,re
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
- AUTO_CALLED, TEST_METHOD_PATTERN, MAGIC_METHODS,
8
- TEST_LIFECYCLE_METHODS, TEST_IMPORT_PATTERNS, TEST_DECORATORS,
9
- DEFAULT_EXCLUDE_FOLDERS
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 _has_test_imports(self, file_path):
147
- try:
148
- with open(file_path, 'r', encoding='utf-8') as f:
149
- content = f.read()
150
-
151
- for test_import in TEST_IMPORT_PATTERNS:
152
- if f"import {test_import}" in content or f"from {test_import}" in content:
153
- return True
154
-
155
- return False
156
- except:
157
- return False
158
-
159
- def _is_test_file(self, file_path):
160
- """check if file locs indicates its a test file"""
161
- file_str = str(file_path).lower()
162
-
163
- if (file_str.endswith("test.py") or
164
- file_str.endswith("_test.py") or
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
- def _has_test_decorators(self, file_path):
175
- """Check if file uses test-related decorators"""
176
- try:
177
- with open(file_path, 'r', encoding='utf-8') as f:
178
- content = f.read()
179
-
180
- for decorator in TEST_DECORATORS:
181
- if f"@{decorator}" in content:
182
- return True
183
-
184
- return False
185
- except:
186
- return False
187
-
188
- def _is_test_related(self, definition):
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
- if "." in definition.name:
191
- class_name = definition.name.rsplit(".", 1)[0]
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:m.references+=1
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
- defs, refs, dyn, exports = proc_file(file, mod)
293
-
294
- for d in defs:
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
- if d.references == 0 and not d.is_exported and d.confidence >= thr:
309
- unused.append(d.to_dict())
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
- tree = ast.parse(Path(file).read_text(encoding="utf-8"))
345
- v = Visitor(mod, file)
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
- return v.defs, v.refs, v.dyn, v.exports
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
- return [], [], set(), set()
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)))