skylos 1.1.12__py3-none-any.whl → 1.2.1__py3-none-any.whl

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/analyzer.py CHANGED
@@ -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)
skylos/cli.py CHANGED
@@ -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:
skylos/constants.py CHANGED
@@ -1,35 +1,42 @@
1
1
  import re
2
+ from pathlib import Path
2
3
 
3
- AUTO_CALLED={"__init__","__enter__","__exit__"}
4
- TEST_METHOD_PATTERN = re.compile(r"^test_\w+$")
5
- 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"]}
6
- TEST_LIFECYCLE_METHODS = {
7
- "setUp", "tearDown", "setUpClass", "tearDownClass",
8
- "setUpModule", "tearDownModule", "setup_method", "teardown_method",
9
- "setup_class", "teardown_class", "setup_function", "teardown_function"
10
- }
11
- TEST_IMPORT_PATTERNS = {
12
- "unittest", "unittest.mock", "mock", "pytest", "nose", "nose2",
13
- "responses", "requests_mock", "freezegun", "factory_boy",
14
- "hypothesis", "sure", "expects", "testfixtures", "faker"
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,
15
12
  }
16
13
 
17
- TEST_DECORATORS = {
18
- "patch", "mock", "pytest.fixture", "pytest.mark", "given",
19
- "responses.activate", "freeze_time", "patch.object", "patch.dict"
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'
20
29
  }
21
30
 
31
+ FRAMEWORK_FILE_RE = re.compile(r"(?:views|handlers|endpoints|routes|api)\.py$", re.I)
32
+
22
33
  DEFAULT_EXCLUDE_FOLDERS = {
23
- "__pycache__",
24
- ".git",
25
- ".pytest_cache",
26
- ".mypy_cache",
27
- ".tox",
28
- "htmlcov",
29
- ".coverage",
30
- "build",
31
- "dist",
32
- "*.egg-info",
33
- "venv",
34
- ".venv"
34
+ "__pycache__", ".git", ".pytest_cache", ".mypy_cache", ".tox",
35
+ "htmlcov", ".coverage", "build", "dist", "*.egg-info", "venv", ".venv"
35
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)))
@@ -0,0 +1,158 @@
1
+ import ast
2
+ import fnmatch
3
+ from pathlib import Path
4
+
5
+ # decorator patterns for each of the more popular frameworks
6
+ FRAMEWORK_DECORATORS = [
7
+ # flask
8
+ "@app.route", "@app.get", "@app.post", "@app.put", "@app.delete",
9
+ "@app.patch", "@app.before_request", "@app.after_request",
10
+ "@app.errorhandler", "@app.teardown_*",
11
+
12
+ # fastapi
13
+ "@router.get", "@router.post", "@router.put", "@router.delete",
14
+ "@router.patch", "@router.head", "@router.options",
15
+ "@router.trace", "@router.websocket",
16
+
17
+ # django
18
+ "@*_required", "@login_required", "@permission_required",
19
+ "@user_passes_test", "@staff_member_required",
20
+
21
+ # pydantic
22
+ "@validator", "@field_validator", "@model_validator", "@root_validator",
23
+
24
+ # celery patterns
25
+ "@task", "@shared_task", "@periodic_task", "@celery_task",
26
+
27
+ # generic ones
28
+ "@route", "@get", "@post", "@put", "@delete", "@patch",
29
+ "@middleware", "@depends", "@inject"
30
+ ]
31
+
32
+ FRAMEWORK_FUNCTIONS = [
33
+ # django view methods
34
+ "get", "post", "put", "patch", "delete", "head", "options", "trace",
35
+ "*_queryset", "get_queryset", "get_object", "get_context_data",
36
+ "*_form", "form_valid", "form_invalid", "get_form_*",
37
+
38
+ # django model methods
39
+ "save", "delete", "clean", "full_clean", "*_delete", "*_save",
40
+
41
+ # generic API endpoints
42
+ "create_*", "update_*", "delete_*", "list_*", "retrieve_*",
43
+ "handle_*", "process_*", "*_handler", "*_view"
44
+ ]
45
+
46
+ FRAMEWORK_IMPORTS = {
47
+ 'flask', 'fastapi', 'django', 'pydantic', 'celery', 'starlette',
48
+ 'sanic', 'tornado', 'pyramid', 'bottle', 'cherrypy', 'web2py',
49
+ 'falcon', 'hug', 'responder', 'quart', 'hypercorn', 'uvicorn'
50
+ }
51
+
52
+ class FrameworkAwareVisitor:
53
+
54
+ def __init__(self, filename=None):
55
+ self.is_framework_file = False
56
+ self.framework_decorated_lines = set()
57
+ self.detected_frameworks = set()
58
+
59
+ if filename:
60
+ self._check_framework_imports_in_file(filename)
61
+
62
+ def visit(self, node):
63
+ method = 'visit_' + node.__class__.__name__
64
+ visitor = getattr(self, method, self.generic_visit)
65
+ return visitor(node)
66
+
67
+ def generic_visit(self, node):
68
+ for field, value in ast.iter_fields(node):
69
+ if isinstance(value, list):
70
+ for item in value:
71
+ if isinstance(item, ast.AST):
72
+ self.visit(item)
73
+ elif isinstance(value, ast.AST):
74
+ self.visit(value)
75
+
76
+ def visit_Import(self, node):
77
+ for alias in node.names:
78
+ if any(fw in alias.name.lower() for fw in FRAMEWORK_IMPORTS):
79
+ self.is_framework_file = True
80
+ self.detected_frameworks.add(alias.name.split('.')[0])
81
+ self.generic_visit(node)
82
+
83
+ def visit_ImportFrom(self, node):
84
+ if node.module:
85
+ module_name = node.module.split('.')[0].lower()
86
+ if module_name in FRAMEWORK_IMPORTS:
87
+ self.is_framework_file = True
88
+ self.detected_frameworks.add(module_name)
89
+ self.generic_visit(node)
90
+
91
+ def visit_FunctionDef(self, node):
92
+ for deco in node.decorator_list:
93
+ decorator_str = self._normalize_decorator(deco)
94
+ if self._matches_framework_pattern(decorator_str, FRAMEWORK_DECORATORS):
95
+ self.framework_decorated_lines.add(node.lineno)
96
+ self.is_framework_file = True
97
+
98
+ if self._matches_framework_pattern(node.name, FRAMEWORK_FUNCTIONS):
99
+ if self.is_framework_file:
100
+ self.framework_decorated_lines.add(node.lineno)
101
+
102
+ self.generic_visit(node)
103
+
104
+ def visit_AsyncFunctionDef(self, node):
105
+ self.visit_FunctionDef(node)
106
+
107
+ def visit_ClassDef(self, node):
108
+ for base in node.bases:
109
+ if isinstance(base, ast.Name):
110
+ if any(pattern in base.id.lower() for pattern in ['view', 'viewset', 'api', 'handler']):
111
+ if self.is_framework_file:
112
+ self.framework_decorated_lines.add(node.lineno)
113
+
114
+ self.generic_visit(node)
115
+
116
+ def _normalize_decorator(self, decorator_node):
117
+ if isinstance(decorator_node, ast.Name):
118
+ return f"@{decorator_node.id}"
119
+ elif isinstance(decorator_node, ast.Attribute):
120
+ if isinstance(decorator_node.value, ast.Name):
121
+ return f"@{decorator_node.value.id}.{decorator_node.attr}"
122
+ else:
123
+ return f"@{decorator_node.attr}"
124
+ elif isinstance(decorator_node, ast.Call):
125
+ return self._normalize_decorator(decorator_node.func)
126
+ return "@unknown"
127
+
128
+ def _matches_framework_pattern(self, text, patterns):
129
+ text_clean = text.lstrip('@')
130
+ return any(fnmatch.fnmatch(text_clean, pattern.lstrip('@')) for pattern in patterns)
131
+
132
+ def _check_framework_imports_in_file(self, filename):
133
+ try:
134
+ content = Path(filename).read_text(encoding='utf-8')
135
+ for framework in FRAMEWORK_IMPORTS:
136
+ if (f'import {framework}' in content or
137
+ f'from {framework}' in content):
138
+ self.is_framework_file = True
139
+ self.detected_frameworks.add(framework)
140
+ break
141
+ except:
142
+ pass
143
+
144
+ def detect_framework_usage(definition, decorator_nodes=None, visitor=None):
145
+ if not visitor:
146
+ return None
147
+
148
+ # very low confidence - likely framework magic
149
+ if definition.line in visitor.framework_decorated_lines:
150
+ return 20
151
+
152
+ # framework file but no direct markers
153
+ if visitor.is_framework_file:
154
+ if (not definition.simple_name.startswith('_') and
155
+ definition.type in ('function', 'method')):
156
+ return 40
157
+
158
+ return None