ballpython 2.0.0__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.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cyclomatic and cognitive complexity analyzer for Python source code.
|
|
3
|
+
|
|
4
|
+
Computes per-function metrics including McCabe cyclomatic complexity,
|
|
5
|
+
Sonar-style cognitive complexity, line count, argument count, return count,
|
|
6
|
+
and maximum nesting depth.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ComplexityMetrics:
|
|
19
|
+
"""Complexity measurements for a single function or method."""
|
|
20
|
+
|
|
21
|
+
filepath: str
|
|
22
|
+
lineno: int
|
|
23
|
+
end_lineno: int | None
|
|
24
|
+
name: str
|
|
25
|
+
qualified_name: str
|
|
26
|
+
cyclomatic: int
|
|
27
|
+
cognitive: int
|
|
28
|
+
lines: int
|
|
29
|
+
args: int
|
|
30
|
+
returns: int
|
|
31
|
+
max_nesting: int
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_complex(self) -> bool:
|
|
35
|
+
"""Quick check for any threshold violation using generous defaults."""
|
|
36
|
+
return (
|
|
37
|
+
self.cyclomatic > 10
|
|
38
|
+
or self.cognitive > 15
|
|
39
|
+
or self.lines > 50
|
|
40
|
+
or self.args > 5
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ComplexityReport:
|
|
46
|
+
"""Full complexity analysis report."""
|
|
47
|
+
|
|
48
|
+
functions: list[ComplexityMetrics] = field(default_factory=list)
|
|
49
|
+
files_scanned: int = 0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def count(self) -> int:
|
|
53
|
+
return len(self.functions)
|
|
54
|
+
|
|
55
|
+
def above_threshold(
|
|
56
|
+
self,
|
|
57
|
+
max_cyclomatic: int = 10,
|
|
58
|
+
max_cognitive: int = 15,
|
|
59
|
+
max_lines: int = 50,
|
|
60
|
+
max_args: int = 5,
|
|
61
|
+
) -> list[ComplexityMetrics]:
|
|
62
|
+
"""Return functions exceeding any configured threshold."""
|
|
63
|
+
return [
|
|
64
|
+
f
|
|
65
|
+
for f in self.functions
|
|
66
|
+
if f.cyclomatic > max_cyclomatic
|
|
67
|
+
or f.cognitive > max_cognitive
|
|
68
|
+
or f.lines > max_lines
|
|
69
|
+
or f.args > max_args
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def average_cyclomatic(self) -> float:
|
|
74
|
+
if not self.functions:
|
|
75
|
+
return 0.0
|
|
76
|
+
return sum(f.cyclomatic for f in self.functions) / len(self.functions)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def average_cognitive(self) -> float:
|
|
80
|
+
if not self.functions:
|
|
81
|
+
return 0.0
|
|
82
|
+
return sum(f.cognitive for f in self.functions) / len(self.functions)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _CyclomaticCounter(ast.NodeVisitor):
|
|
86
|
+
"""Counts McCabe cyclomatic complexity decision points in a function body."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self.complexity = 1 # Base complexity
|
|
90
|
+
# Tracks def/async-def nesting so the counter can visit the entry
|
|
91
|
+
# function's own body (depth reaches 1) while refusing to descend
|
|
92
|
+
# into any function defined *inside* it (depth would reach 2+).
|
|
93
|
+
# Without this, a nested helper's branches get double-counted: once
|
|
94
|
+
# against itself, and again against every function that encloses it.
|
|
95
|
+
self._function_depth = 0
|
|
96
|
+
|
|
97
|
+
def visit_If(self, node: ast.If) -> None:
|
|
98
|
+
self.complexity += 1
|
|
99
|
+
self.generic_visit(node)
|
|
100
|
+
|
|
101
|
+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
|
|
102
|
+
self.complexity += 1
|
|
103
|
+
self.generic_visit(node)
|
|
104
|
+
|
|
105
|
+
visit_AsyncFor = visit_For
|
|
106
|
+
|
|
107
|
+
def visit_While(self, node: ast.While) -> None:
|
|
108
|
+
self.complexity += 1
|
|
109
|
+
self.generic_visit(node)
|
|
110
|
+
|
|
111
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
112
|
+
self.complexity += 1
|
|
113
|
+
self.generic_visit(node)
|
|
114
|
+
|
|
115
|
+
def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
|
|
116
|
+
self.complexity += 1
|
|
117
|
+
self.generic_visit(node)
|
|
118
|
+
|
|
119
|
+
visit_AsyncWith = visit_With
|
|
120
|
+
|
|
121
|
+
def visit_Assert(self, node: ast.Assert) -> None:
|
|
122
|
+
self.complexity += 1
|
|
123
|
+
self.generic_visit(node)
|
|
124
|
+
|
|
125
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> None:
|
|
126
|
+
# Each 'and' or 'or' adds a decision branch
|
|
127
|
+
self.complexity += len(node.values) - 1
|
|
128
|
+
self.generic_visit(node)
|
|
129
|
+
|
|
130
|
+
def visit_IfExp(self, node: ast.IfExp) -> None:
|
|
131
|
+
# Ternary expression: x if cond else y
|
|
132
|
+
self.complexity += 1
|
|
133
|
+
self.generic_visit(node)
|
|
134
|
+
|
|
135
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
136
|
+
self.complexity += 1
|
|
137
|
+
# Each filter condition is an additional branch
|
|
138
|
+
self.complexity += len(node.ifs)
|
|
139
|
+
self.generic_visit(node)
|
|
140
|
+
|
|
141
|
+
def visit_Match(self, node: ast.Match) -> None:
|
|
142
|
+
# Each case is a branch
|
|
143
|
+
self.complexity += len(node.cases)
|
|
144
|
+
self.generic_visit(node)
|
|
145
|
+
|
|
146
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
147
|
+
self._function_depth += 1
|
|
148
|
+
if self._function_depth == 1:
|
|
149
|
+
# This is the entry function being scored; walk its body.
|
|
150
|
+
self.generic_visit(node)
|
|
151
|
+
# A nested def is scored as its own independent entry elsewhere;
|
|
152
|
+
# do not descend into it from here.
|
|
153
|
+
self._function_depth -= 1
|
|
154
|
+
|
|
155
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _CognitiveCounter(ast.NodeVisitor):
|
|
159
|
+
"""Computes Sonar-style cognitive complexity score."""
|
|
160
|
+
|
|
161
|
+
def __init__(self) -> None:
|
|
162
|
+
self.score = 0
|
|
163
|
+
self._nesting = 0
|
|
164
|
+
|
|
165
|
+
def _increment(self, nesting_penalty: bool = True) -> None:
|
|
166
|
+
self.score += 1
|
|
167
|
+
if nesting_penalty:
|
|
168
|
+
self.score += self._nesting
|
|
169
|
+
|
|
170
|
+
def _handle_elif(self, orelse_node: ast.If) -> None:
|
|
171
|
+
self.score += 1
|
|
172
|
+
self._nesting += 1
|
|
173
|
+
for child in orelse_node.body:
|
|
174
|
+
self.visit(child)
|
|
175
|
+
self._nesting -= 1
|
|
176
|
+
for sub in orelse_node.orelse:
|
|
177
|
+
if isinstance(sub, ast.If):
|
|
178
|
+
self.visit_If(sub)
|
|
179
|
+
else:
|
|
180
|
+
self.visit(sub)
|
|
181
|
+
|
|
182
|
+
def _handle_else(self, orelse_node: ast.AST) -> None:
|
|
183
|
+
self.score += 1
|
|
184
|
+
self._nesting += 1
|
|
185
|
+
self.visit(orelse_node)
|
|
186
|
+
self._nesting -= 1
|
|
187
|
+
|
|
188
|
+
def visit_If(self, node: ast.If) -> None:
|
|
189
|
+
self._increment(nesting_penalty=True)
|
|
190
|
+
self._nesting += 1
|
|
191
|
+
for child in node.body:
|
|
192
|
+
self.visit(child)
|
|
193
|
+
self._nesting -= 1
|
|
194
|
+
|
|
195
|
+
for orelse_node in node.orelse:
|
|
196
|
+
if isinstance(orelse_node, ast.If):
|
|
197
|
+
self._handle_elif(orelse_node)
|
|
198
|
+
else:
|
|
199
|
+
self._handle_else(orelse_node)
|
|
200
|
+
|
|
201
|
+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
|
|
202
|
+
self._increment(nesting_penalty=True)
|
|
203
|
+
self._nesting += 1
|
|
204
|
+
for child in node.body:
|
|
205
|
+
self.visit(child)
|
|
206
|
+
self._nesting -= 1
|
|
207
|
+
for child in node.orelse:
|
|
208
|
+
self.visit(child)
|
|
209
|
+
|
|
210
|
+
visit_AsyncFor = visit_For
|
|
211
|
+
|
|
212
|
+
def visit_While(self, node: ast.While) -> None:
|
|
213
|
+
self._increment(nesting_penalty=True)
|
|
214
|
+
self._nesting += 1
|
|
215
|
+
for child in node.body:
|
|
216
|
+
self.visit(child)
|
|
217
|
+
self._nesting -= 1
|
|
218
|
+
for child in node.orelse:
|
|
219
|
+
self.visit(child)
|
|
220
|
+
|
|
221
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
222
|
+
self._increment(nesting_penalty=True)
|
|
223
|
+
self._nesting += 1
|
|
224
|
+
for child in node.body:
|
|
225
|
+
self.visit(child)
|
|
226
|
+
self._nesting -= 1
|
|
227
|
+
|
|
228
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> None:
|
|
229
|
+
# Sequences of same operator (a and b and c) count as 1
|
|
230
|
+
# Mixed operators count each change
|
|
231
|
+
self.score += 1
|
|
232
|
+
self.generic_visit(node)
|
|
233
|
+
|
|
234
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
235
|
+
# The caller only ever feeds this visitor the *children* of the
|
|
236
|
+
# function being scored (see _extract_functions), never the
|
|
237
|
+
# function's own node, so any FunctionDef reaching here is always a
|
|
238
|
+
# nested helper. Nested functions get their own report entry and
|
|
239
|
+
# must not contribute to the enclosing function's cognitive score.
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
243
|
+
|
|
244
|
+
def visit_IfExp(self, node: ast.IfExp) -> None:
|
|
245
|
+
self._increment(nesting_penalty=True)
|
|
246
|
+
self.generic_visit(node)
|
|
247
|
+
|
|
248
|
+
def visit_Break(self, node: ast.Break) -> None:
|
|
249
|
+
self.score += 1
|
|
250
|
+
|
|
251
|
+
def visit_Continue(self, node: ast.Continue) -> None:
|
|
252
|
+
self.score += 1
|
|
253
|
+
|
|
254
|
+
def visit_Match(self, node: ast.Match) -> None:
|
|
255
|
+
self._increment(nesting_penalty=True)
|
|
256
|
+
self._nesting += 1
|
|
257
|
+
for case in node.cases:
|
|
258
|
+
for child in case.body:
|
|
259
|
+
self.visit(child)
|
|
260
|
+
self._nesting -= 1
|
|
261
|
+
|
|
262
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
263
|
+
self._increment(nesting_penalty=True)
|
|
264
|
+
self._nesting += 1
|
|
265
|
+
for child in node.body:
|
|
266
|
+
self.visit(child)
|
|
267
|
+
self._nesting -= 1
|
|
268
|
+
for handler in node.handlers:
|
|
269
|
+
self.visit(handler)
|
|
270
|
+
for child in node.finalbody:
|
|
271
|
+
self.visit(child)
|
|
272
|
+
for child in node.orelse:
|
|
273
|
+
self.visit(child)
|
|
274
|
+
|
|
275
|
+
# Python 3.11+ TryStar
|
|
276
|
+
def visit_TryStar(self, node: ast.TryStar) -> None: # type: ignore[attr-defined]
|
|
277
|
+
self._increment(nesting_penalty=True)
|
|
278
|
+
self._nesting += 1
|
|
279
|
+
for child in node.body:
|
|
280
|
+
self.visit(child)
|
|
281
|
+
self._nesting -= 1
|
|
282
|
+
for handler in node.handlers:
|
|
283
|
+
self.visit(handler)
|
|
284
|
+
for child in node.finalbody:
|
|
285
|
+
self.visit(child)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _NestingDepthCounter(ast.NodeVisitor):
|
|
289
|
+
"""Tracks maximum nesting depth within a function body."""
|
|
290
|
+
|
|
291
|
+
NESTING_NODES = (
|
|
292
|
+
ast.If,
|
|
293
|
+
ast.For,
|
|
294
|
+
ast.AsyncFor,
|
|
295
|
+
ast.While,
|
|
296
|
+
ast.With,
|
|
297
|
+
ast.AsyncWith,
|
|
298
|
+
ast.Try,
|
|
299
|
+
ast.ExceptHandler,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def __init__(self) -> None:
|
|
303
|
+
self.max_depth = 0
|
|
304
|
+
self._current_depth = 0
|
|
305
|
+
self._function_depth = 0
|
|
306
|
+
|
|
307
|
+
def generic_visit(self, node: ast.AST) -> None:
|
|
308
|
+
if isinstance(node, self.NESTING_NODES):
|
|
309
|
+
self._current_depth += 1
|
|
310
|
+
self.max_depth = max(self.max_depth, self._current_depth)
|
|
311
|
+
super().generic_visit(node)
|
|
312
|
+
self._current_depth -= 1
|
|
313
|
+
else:
|
|
314
|
+
super().generic_visit(node)
|
|
315
|
+
|
|
316
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
317
|
+
self._function_depth += 1
|
|
318
|
+
if self._function_depth == 1:
|
|
319
|
+
# Entry function being scored — walk its body normally.
|
|
320
|
+
self.generic_visit(node)
|
|
321
|
+
# A nested def starts counting from its own zero depth in its own
|
|
322
|
+
# report entry; it must not extend the enclosing function's depth.
|
|
323
|
+
self._function_depth -= 1
|
|
324
|
+
|
|
325
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class ComplexityAnalyzer:
|
|
329
|
+
"""Analyzes per-function complexity metrics across Python source files."""
|
|
330
|
+
|
|
331
|
+
IGNORE_DIRS = frozenset(
|
|
332
|
+
{
|
|
333
|
+
".git",
|
|
334
|
+
".venv",
|
|
335
|
+
"venv",
|
|
336
|
+
"env",
|
|
337
|
+
"__pycache__",
|
|
338
|
+
"build",
|
|
339
|
+
"dist",
|
|
340
|
+
".tox",
|
|
341
|
+
".mypy_cache",
|
|
342
|
+
".pytest_cache",
|
|
343
|
+
".ruff_cache",
|
|
344
|
+
"site-packages",
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def analyze_source(
|
|
349
|
+
self, source: str, filename: str = "<unknown>"
|
|
350
|
+
) -> ComplexityReport:
|
|
351
|
+
"""Analyze complexity metrics for all functions in a source string."""
|
|
352
|
+
try:
|
|
353
|
+
tree = ast.parse(source, filename=filename)
|
|
354
|
+
except SyntaxError:
|
|
355
|
+
return ComplexityReport(files_scanned=1)
|
|
356
|
+
|
|
357
|
+
functions = self._extract_functions(tree, filename, source)
|
|
358
|
+
return ComplexityReport(functions=functions, files_scanned=1)
|
|
359
|
+
|
|
360
|
+
def _collect_file_metrics(
|
|
361
|
+
self, current_root: str, fname: str
|
|
362
|
+
) -> list[ComplexityMetrics] | None:
|
|
363
|
+
if not fname.endswith(".py"):
|
|
364
|
+
return None
|
|
365
|
+
fpath = Path(current_root) / fname
|
|
366
|
+
try:
|
|
367
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
368
|
+
return self.analyze_source(content, filename=str(fpath)).functions
|
|
369
|
+
except OSError:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
def analyze_project(self, root_dir: Path | str) -> ComplexityReport:
|
|
373
|
+
"""Analyze complexity across all Python files in a project."""
|
|
374
|
+
root = Path(root_dir).resolve()
|
|
375
|
+
all_functions: list[ComplexityMetrics] = []
|
|
376
|
+
files_scanned = 0
|
|
377
|
+
|
|
378
|
+
for current_root, dirs, filenames in os.walk(root):
|
|
379
|
+
dirs[:] = [
|
|
380
|
+
d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
|
|
381
|
+
]
|
|
382
|
+
for fname in filenames:
|
|
383
|
+
file_funcs = self._collect_file_metrics(current_root, fname)
|
|
384
|
+
if file_funcs is not None:
|
|
385
|
+
all_functions.extend(file_funcs)
|
|
386
|
+
files_scanned += 1
|
|
387
|
+
|
|
388
|
+
all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
|
|
389
|
+
return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _count_function_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
393
|
+
all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
|
|
394
|
+
count = len(all_args)
|
|
395
|
+
if node.args.vararg:
|
|
396
|
+
count += 1
|
|
397
|
+
if node.args.kwarg:
|
|
398
|
+
count += 1
|
|
399
|
+
if all_args and all_args[0].arg in ("self", "cls"):
|
|
400
|
+
count -= 1
|
|
401
|
+
return count
|
|
402
|
+
|
|
403
|
+
def _build_function_metrics(
|
|
404
|
+
self,
|
|
405
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
406
|
+
tree: ast.Module,
|
|
407
|
+
filename: str,
|
|
408
|
+
) -> ComplexityMetrics:
|
|
409
|
+
start = node.lineno
|
|
410
|
+
end = node.end_lineno or start
|
|
411
|
+
return_count = sum(
|
|
412
|
+
1 for c in ast.walk(node) if isinstance(c, ast.Return) and c is not node
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
cyclo = _CyclomaticCounter()
|
|
416
|
+
cyclo.visit(node)
|
|
417
|
+
|
|
418
|
+
cog = _CognitiveCounter()
|
|
419
|
+
for child in node.body:
|
|
420
|
+
cog.visit(child)
|
|
421
|
+
|
|
422
|
+
nesting = _NestingDepthCounter()
|
|
423
|
+
nesting.visit(node)
|
|
424
|
+
|
|
425
|
+
return ComplexityMetrics(
|
|
426
|
+
filepath=filename,
|
|
427
|
+
lineno=start,
|
|
428
|
+
end_lineno=end,
|
|
429
|
+
name=node.name,
|
|
430
|
+
qualified_name=self._get_qualified_name(node, tree),
|
|
431
|
+
cyclomatic=cyclo.complexity,
|
|
432
|
+
cognitive=cog.score,
|
|
433
|
+
lines=end - start + 1,
|
|
434
|
+
args=self._count_function_args(node),
|
|
435
|
+
returns=return_count,
|
|
436
|
+
max_nesting=nesting.max_depth,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def _extract_functions(
|
|
440
|
+
self,
|
|
441
|
+
tree: ast.Module,
|
|
442
|
+
filename: str,
|
|
443
|
+
source: str,
|
|
444
|
+
) -> list[ComplexityMetrics]:
|
|
445
|
+
"""Extract and analyze all function/method definitions from an AST."""
|
|
446
|
+
results: list[ComplexityMetrics] = []
|
|
447
|
+
for node in ast.walk(tree):
|
|
448
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
449
|
+
results.append(self._build_function_metrics(node, tree, filename))
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _get_qualified_name(
|
|
454
|
+
target: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
455
|
+
tree: ast.Module,
|
|
456
|
+
) -> str:
|
|
457
|
+
"""Resolve a function's qualified name including class scope."""
|
|
458
|
+
parents: dict[int, ast.AST] = {}
|
|
459
|
+
for parent in ast.walk(tree):
|
|
460
|
+
for child in ast.iter_child_nodes(parent):
|
|
461
|
+
parents[id(child)] = parent
|
|
462
|
+
|
|
463
|
+
names: list[str] = [target.name]
|
|
464
|
+
current: ast.AST = target
|
|
465
|
+
while id(current) in parents:
|
|
466
|
+
parent = parents[id(current)]
|
|
467
|
+
if isinstance(
|
|
468
|
+
parent, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
469
|
+
):
|
|
470
|
+
names.insert(0, parent.name)
|
|
471
|
+
current = parent
|
|
472
|
+
|
|
473
|
+
return ".".join(names)
|