ballpython 2.0.0__py3-none-any.whl → 2.0.2__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/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +59 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/top_level.txt +0 -0
pycleaner/syntax_healer.py
CHANGED
|
@@ -26,6 +26,7 @@ class SyntaxHealResult:
|
|
|
26
26
|
error_message: str | None = None
|
|
27
27
|
error_lineno: int | None = None
|
|
28
28
|
error_offset: int | None = None
|
|
29
|
+
diagnostic: str | None = None
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class SyntaxHealer:
|
|
@@ -101,9 +102,9 @@ class SyntaxHealer:
|
|
|
101
102
|
re.MULTILINE,
|
|
102
103
|
)
|
|
103
104
|
|
|
104
|
-
# Python 2 except statement: except Exception, e:
|
|
105
|
+
# Python 2 except statement: except Exception, e: or except (E1, E2), e:
|
|
105
106
|
_PY2_EXCEPT_PATTERN = re.compile(
|
|
106
|
-
r"^([ \t]*except\s+[\w\.\
|
|
107
|
+
r"^([ \t]*except\s+(?:\((?:[^()]|\([^()]*\))*\)|[\w\.\s]+)),\s*([a-zA-Z_]\w*)\s*:(.*)$",
|
|
107
108
|
re.MULTILINE,
|
|
108
109
|
)
|
|
109
110
|
|
|
@@ -183,6 +184,12 @@ class SyntaxHealer:
|
|
|
183
184
|
):
|
|
184
185
|
return code, repairs
|
|
185
186
|
|
|
187
|
+
if apply_step(
|
|
188
|
+
self._fix_unindented_blocks,
|
|
189
|
+
"Re-indented {count} under-indented block(s) following compound statement headers",
|
|
190
|
+
):
|
|
191
|
+
return code, repairs
|
|
192
|
+
|
|
186
193
|
code, d_repairs = self._fix_unbalanced_delimiters(code, filename=filename)
|
|
187
194
|
repairs.extend(d_repairs)
|
|
188
195
|
return code, repairs
|
|
@@ -199,15 +206,136 @@ class SyntaxHealer:
|
|
|
199
206
|
return SyntaxHealResult(code=repaired, is_valid=True, repairs=repairs)
|
|
200
207
|
|
|
201
208
|
err_msg, lineno, offset = final_error
|
|
209
|
+
diag = self.format_diagnostic(source, filename=filename, error=final_error)
|
|
202
210
|
return SyntaxHealResult(
|
|
203
|
-
code=
|
|
211
|
+
code=repaired,
|
|
204
212
|
is_valid=False,
|
|
205
|
-
repairs=
|
|
213
|
+
repairs=repairs,
|
|
206
214
|
error_message=err_msg,
|
|
207
215
|
error_lineno=lineno,
|
|
208
216
|
error_offset=offset,
|
|
217
|
+
diagnostic=diag,
|
|
209
218
|
)
|
|
210
219
|
|
|
220
|
+
def _fix_unindented_blocks(self, source: str) -> tuple[str, int]:
|
|
221
|
+
"""
|
|
222
|
+
Auto-heal under-indented blocks following compound statement headers.
|
|
223
|
+
Repairs situations where docstrings or body statements were not indented
|
|
224
|
+
relative to def, class, if, while, for, with, try, except, etc.
|
|
225
|
+
"""
|
|
226
|
+
lines = source.splitlines()
|
|
227
|
+
repaired_count = 0
|
|
228
|
+
max_passes = 10
|
|
229
|
+
|
|
230
|
+
for _ in range(max_passes):
|
|
231
|
+
try:
|
|
232
|
+
ast.parse("\n".join(lines))
|
|
233
|
+
break
|
|
234
|
+
except IndentationError as err:
|
|
235
|
+
msg = err.msg or ""
|
|
236
|
+
if "expected an indented block" not in msg:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
header_idx = None
|
|
240
|
+
m = re.search(r"on line (\d+)", msg)
|
|
241
|
+
if m:
|
|
242
|
+
header_idx = int(m.group(1)) - 1
|
|
243
|
+
else:
|
|
244
|
+
target_idx = (err.lineno or 2) - 1
|
|
245
|
+
for i in range(target_idx - 1, -1, -1):
|
|
246
|
+
stripped = lines[i].strip()
|
|
247
|
+
if stripped and not stripped.startswith("#"):
|
|
248
|
+
header_idx = i
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if header_idx is None or header_idx < 0 or header_idx >= len(lines):
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
header_line = lines[header_idx]
|
|
255
|
+
header_indent = len(header_line) - len(header_line.lstrip())
|
|
256
|
+
desired_indent = header_indent + 4
|
|
257
|
+
start_idx = header_idx + 1
|
|
258
|
+
|
|
259
|
+
i = start_idx
|
|
260
|
+
in_multiline_str = False
|
|
261
|
+
multiline_quote = ""
|
|
262
|
+
|
|
263
|
+
while i < len(lines):
|
|
264
|
+
line = lines[i]
|
|
265
|
+
stripped = line.strip()
|
|
266
|
+
|
|
267
|
+
if not stripped:
|
|
268
|
+
i += 1
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
curr_indent = len(line) - len(line.lstrip())
|
|
272
|
+
|
|
273
|
+
was_in_multiline = in_multiline_str
|
|
274
|
+
if in_multiline_str:
|
|
275
|
+
if multiline_quote in stripped:
|
|
276
|
+
in_multiline_str = False
|
|
277
|
+
else:
|
|
278
|
+
if stripped.startswith(('"""', chr(39) * 3)):
|
|
279
|
+
quote = stripped[:3]
|
|
280
|
+
if stripped.count(quote) % 2 == 1:
|
|
281
|
+
in_multiline_str = True
|
|
282
|
+
multiline_quote = quote
|
|
283
|
+
|
|
284
|
+
is_sibling_declaration = (
|
|
285
|
+
curr_indent <= header_indent
|
|
286
|
+
and stripped.startswith(("def ", "class ", "async def ", "@"))
|
|
287
|
+
)
|
|
288
|
+
is_outer_scope = (
|
|
289
|
+
header_indent > 0
|
|
290
|
+
and curr_indent == 0
|
|
291
|
+
and not was_in_multiline
|
|
292
|
+
and not stripped.startswith(('"""', chr(39) * 3))
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
not was_in_multiline
|
|
297
|
+
and not in_multiline_str
|
|
298
|
+
and (is_sibling_declaration or is_outer_scope)
|
|
299
|
+
):
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
if curr_indent < desired_indent:
|
|
303
|
+
shift = desired_indent - curr_indent
|
|
304
|
+
lines[i] = (" " * shift) + line
|
|
305
|
+
i += 1
|
|
306
|
+
|
|
307
|
+
repaired_count += 1
|
|
308
|
+
except SyntaxError:
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
return "\n".join(lines), repaired_count
|
|
312
|
+
|
|
313
|
+
def format_diagnostic(
|
|
314
|
+
self,
|
|
315
|
+
source: str,
|
|
316
|
+
filename: str = "<unknown>",
|
|
317
|
+
error: tuple[str, int, int] | None = None,
|
|
318
|
+
) -> str:
|
|
319
|
+
"""Format an unhealed syntax error into a rich, compiler-grade diagnostic banner."""
|
|
320
|
+
err = error or self._check_syntax(source, filename)
|
|
321
|
+
if err is None:
|
|
322
|
+
return ""
|
|
323
|
+
msg, lineno, offset = err
|
|
324
|
+
lines = source.splitlines()
|
|
325
|
+
line_idx = max(0, min(lineno - 1, len(lines) - 1)) if lines else 0
|
|
326
|
+
error_line = lines[line_idx] if lines else ""
|
|
327
|
+
pointer_indent = " " * max(0, offset - 1)
|
|
328
|
+
|
|
329
|
+
diagnostic_lines = [
|
|
330
|
+
f"❌ SyntaxError in {filename}:{lineno}:{offset}",
|
|
331
|
+
f" --> {filename}:{lineno}:{offset}",
|
|
332
|
+
" |",
|
|
333
|
+
f"{lineno:3d} | {error_line}",
|
|
334
|
+
f" | {pointer_indent}^",
|
|
335
|
+
f" = Error: {msg}",
|
|
336
|
+
]
|
|
337
|
+
return "\n".join(diagnostic_lines)
|
|
338
|
+
|
|
211
339
|
def _check_syntax(self, code: str, filename: str) -> tuple[str, int, int] | None:
|
|
212
340
|
"""Parse source code into AST. Returns None on success, or (msg, lineno, offset) on error."""
|
|
213
341
|
try:
|
|
@@ -400,7 +528,9 @@ class SyntaxHealer:
|
|
|
400
528
|
try:
|
|
401
529
|
tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
|
|
402
530
|
except tokenize.TokenError:
|
|
403
|
-
|
|
531
|
+
tokens = self._tokenize_with_recovered_delimiters(code)
|
|
532
|
+
if tokens is None:
|
|
533
|
+
return code, 0
|
|
404
534
|
|
|
405
535
|
insertions = self._find_missing_colon_insertions(tokens)
|
|
406
536
|
if not insertions:
|
|
@@ -415,6 +545,21 @@ class SyntaxHealer:
|
|
|
415
545
|
|
|
416
546
|
return "".join(lines), len(insertions)
|
|
417
547
|
|
|
548
|
+
def _tokenize_with_recovered_delimiters(
|
|
549
|
+
self, code: str
|
|
550
|
+
) -> list[tokenize.TokenInfo] | None:
|
|
551
|
+
"""Tokenize code that fails tokenization only due to unclosed delimiters."""
|
|
552
|
+
unclosed = self._scan_unclosed_delimiters(code)
|
|
553
|
+
if not unclosed:
|
|
554
|
+
return None
|
|
555
|
+
closers = "".join(item[0] for item in reversed(unclosed))
|
|
556
|
+
padded = code if code.endswith("\n") else code + "\n"
|
|
557
|
+
padded = padded + closers + "\n"
|
|
558
|
+
try:
|
|
559
|
+
return list(tokenize.generate_tokens(io.StringIO(padded).readline))
|
|
560
|
+
except tokenize.TokenError:
|
|
561
|
+
return None
|
|
562
|
+
|
|
418
563
|
@staticmethod
|
|
419
564
|
def _is_standalone_assignment(chars: Sequence[str], i: int, depth: int) -> bool:
|
|
420
565
|
if chars[i] != "=" or depth != 0:
|
|
@@ -524,16 +669,16 @@ class SyntaxHealer:
|
|
|
524
669
|
return new_code, count
|
|
525
670
|
|
|
526
671
|
@staticmethod
|
|
527
|
-
def _scan_unclosed_delimiters(code: str) -> list[str]:
|
|
528
|
-
stack: list[str] = []
|
|
672
|
+
def _scan_unclosed_delimiters(code: str) -> list[tuple[str, int, int]]:
|
|
673
|
+
stack: list[tuple[str, int, int]] = []
|
|
529
674
|
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
530
675
|
closing = {")": "(", "]": "[", "}": "{"}
|
|
531
676
|
try:
|
|
532
677
|
for tok in tokenize.generate_tokens(io.StringIO(code).readline):
|
|
533
678
|
if tok.type == tokenize.OP:
|
|
534
679
|
if tok.string in pairs:
|
|
535
|
-
stack.append(pairs[tok.string])
|
|
536
|
-
elif tok.string in closing and stack and stack[-1] == tok.string:
|
|
680
|
+
stack.append((pairs[tok.string], tok.start[0], tok.start[1]))
|
|
681
|
+
elif tok.string in closing and stack and stack[-1][0] == tok.string:
|
|
537
682
|
stack.pop()
|
|
538
683
|
except tokenize.TokenError:
|
|
539
684
|
# Incomplete token stream will be healed by delimiter reconstruction
|
|
@@ -561,6 +706,30 @@ class SyntaxHealer:
|
|
|
561
706
|
lines[-1] = f"{last_line.rstrip()}{closing_str}{ending}"
|
|
562
707
|
return "".join(lines)
|
|
563
708
|
|
|
709
|
+
def _append_delimiters_to_line(
|
|
710
|
+
self, code: str, lineno: int, closing_str: str
|
|
711
|
+
) -> str:
|
|
712
|
+
"""Append closing delimiters at the end of a specific 1-based line."""
|
|
713
|
+
lines = code.splitlines(keepends=True)
|
|
714
|
+
if not 1 <= lineno <= len(lines):
|
|
715
|
+
return code
|
|
716
|
+
|
|
717
|
+
target_line = lines[lineno - 1]
|
|
718
|
+
comment_idx = target_line.find("#")
|
|
719
|
+
if comment_idx != -1:
|
|
720
|
+
pre = target_line[:comment_idx].rstrip()
|
|
721
|
+
post = target_line[comment_idx:]
|
|
722
|
+
lines[lineno - 1] = f"{pre}{closing_str} {post}"
|
|
723
|
+
return "".join(lines)
|
|
724
|
+
|
|
725
|
+
ending = (
|
|
726
|
+
"\r\n"
|
|
727
|
+
if target_line.endswith("\r\n")
|
|
728
|
+
else ("\n" if target_line.endswith("\n") else "")
|
|
729
|
+
)
|
|
730
|
+
lines[lineno - 1] = f"{target_line.rstrip()}{closing_str}{ending}"
|
|
731
|
+
return "".join(lines)
|
|
732
|
+
|
|
564
733
|
def _fix_unbalanced_delimiters(
|
|
565
734
|
self, code: str, filename: str = "<unknown>"
|
|
566
735
|
) -> tuple[str, list[str]]:
|
|
@@ -569,9 +738,18 @@ class SyntaxHealer:
|
|
|
569
738
|
if not stack:
|
|
570
739
|
return code, []
|
|
571
740
|
|
|
572
|
-
closing_str = "".join(reversed(stack))
|
|
741
|
+
closing_str = "".join(item[0] for item in reversed(stack))
|
|
573
742
|
candidate = self._append_delimiters_to_last_line(code, closing_str)
|
|
574
743
|
if self._check_syntax(candidate, filename) is None:
|
|
575
744
|
return candidate, [f"Closed unclosed delimiter(s): {closing_str}"]
|
|
576
745
|
|
|
746
|
+
inline_candidate = self._append_delimiters_to_line(
|
|
747
|
+
code, stack[-1][1], closing_str
|
|
748
|
+
)
|
|
749
|
+
if (
|
|
750
|
+
inline_candidate != code
|
|
751
|
+
and self._check_syntax(inline_candidate, filename) is None
|
|
752
|
+
):
|
|
753
|
+
return inline_candidate, [f"Closed unclosed delimiter(s): {closing_str}"]
|
|
754
|
+
|
|
577
755
|
return code, []
|
pycleaner/taint_engine.py
CHANGED
|
@@ -10,10 +10,13 @@ SQL Injection, Path Traversal, SSRF, Deserialization).
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import ast
|
|
13
|
+
from collections.abc import Sequence
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import ClassVar
|
|
16
17
|
|
|
18
|
+
from pycleaner.discovery import collect_project_python_files
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
@dataclass(slots=True)
|
|
19
22
|
class TaintFinding:
|
|
@@ -259,19 +262,16 @@ class TaintEngine:
|
|
|
259
262
|
def __init__(self) -> None:
|
|
260
263
|
self.function_summaries: dict[str, FunctionTaintSummary] = {}
|
|
261
264
|
|
|
262
|
-
def scan_path(
|
|
265
|
+
def scan_path(
|
|
266
|
+
self, target: str | Path, exclude_patterns: Sequence[str] = ()
|
|
267
|
+
) -> TaintReport:
|
|
263
268
|
"""Scan a file or directory for dataflow taint vulnerabilities."""
|
|
264
269
|
path = Path(target)
|
|
265
270
|
if path.is_file():
|
|
266
271
|
files = [path] if path.suffix == ".py" else []
|
|
267
272
|
elif path.is_dir():
|
|
268
|
-
files =
|
|
269
|
-
|
|
270
|
-
for f in path.rglob("*.py")
|
|
271
|
-
if not any(
|
|
272
|
-
part.startswith((".", "build", "dist", "venv", "__pycache__"))
|
|
273
|
-
for part in f.parts
|
|
274
|
-
)
|
|
273
|
+
files = collect_project_python_files(
|
|
274
|
+
path, exclude_patterns=exclude_patterns
|
|
275
275
|
)
|
|
276
276
|
else:
|
|
277
277
|
return TaintReport()
|
pycleaner/test_generator.py
CHANGED
|
@@ -26,6 +26,7 @@ class TestCase:
|
|
|
26
26
|
is_async: bool = False
|
|
27
27
|
expected_exception: str | None = None
|
|
28
28
|
assertion_stmt: str = ""
|
|
29
|
+
parent_class: str | None = None
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
@dataclass(slots=True)
|
|
@@ -36,12 +37,62 @@ class GeneratedTestSuite:
|
|
|
36
37
|
module_name: str
|
|
37
38
|
test_cases: list[TestCase] = field(default_factory=list)
|
|
38
39
|
rendered_code: str = ""
|
|
40
|
+
mutation_kill_rate: float = 1.0
|
|
41
|
+
mutants_tested: int = 0
|
|
42
|
+
mutants_killed: int = 0
|
|
39
43
|
|
|
40
44
|
@property
|
|
41
45
|
def test_count(self) -> int:
|
|
42
46
|
return len(self.test_cases)
|
|
43
47
|
|
|
44
48
|
|
|
49
|
+
class AstMutator(ast.NodeTransformer):
|
|
50
|
+
"""Generates first-order mutation variants to measure test killing efficacy."""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.mutations_applied: int = 0
|
|
54
|
+
|
|
55
|
+
def visit_Compare(self, node: ast.Compare) -> ast.AST:
|
|
56
|
+
self.generic_visit(node)
|
|
57
|
+
new_ops = []
|
|
58
|
+
for op in node.ops:
|
|
59
|
+
if isinstance(op, ast.Lt):
|
|
60
|
+
new_ops.append(ast.GtE())
|
|
61
|
+
self.mutations_applied += 1
|
|
62
|
+
elif isinstance(op, ast.Gt):
|
|
63
|
+
new_ops.append(ast.LtE())
|
|
64
|
+
self.mutations_applied += 1
|
|
65
|
+
elif isinstance(op, ast.Eq):
|
|
66
|
+
new_ops.append(ast.NotEq())
|
|
67
|
+
self.mutations_applied += 1
|
|
68
|
+
elif isinstance(op, ast.NotEq):
|
|
69
|
+
new_ops.append(ast.Eq())
|
|
70
|
+
self.mutations_applied += 1
|
|
71
|
+
elif isinstance(op, ast.LtE):
|
|
72
|
+
new_ops.append(ast.Gt())
|
|
73
|
+
self.mutations_applied += 1
|
|
74
|
+
elif isinstance(op, ast.GtE):
|
|
75
|
+
new_ops.append(ast.Lt())
|
|
76
|
+
self.mutations_applied += 1
|
|
77
|
+
else:
|
|
78
|
+
new_ops.append(op)
|
|
79
|
+
node.ops = new_ops
|
|
80
|
+
return node
|
|
81
|
+
|
|
82
|
+
def visit_BinOp(self, node: ast.BinOp) -> ast.AST:
|
|
83
|
+
self.generic_visit(node)
|
|
84
|
+
if isinstance(node.op, ast.Add):
|
|
85
|
+
node.op = ast.Sub()
|
|
86
|
+
self.mutations_applied += 1
|
|
87
|
+
elif isinstance(node.op, ast.Sub):
|
|
88
|
+
node.op = ast.Add()
|
|
89
|
+
self.mutations_applied += 1
|
|
90
|
+
elif isinstance(node.op, ast.Mult):
|
|
91
|
+
node.op = ast.FloorDiv()
|
|
92
|
+
self.mutations_applied += 1
|
|
93
|
+
return node
|
|
94
|
+
|
|
95
|
+
|
|
45
96
|
_PARAM_NAME_PATTERNS: tuple[tuple[tuple[str, ...], tuple[str, str, str]], ...] = (
|
|
46
97
|
(("file", "path", "dest", "src"), ("'sample_path.txt'", "''", "12345")),
|
|
47
98
|
(
|
|
@@ -129,13 +180,30 @@ class TestGenerator:
|
|
|
129
180
|
rendered_code=f"# Failed to parse {path.name} due to syntax error\n",
|
|
130
181
|
)
|
|
131
182
|
|
|
183
|
+
mutator = AstMutator()
|
|
184
|
+
try:
|
|
185
|
+
mutator.visit(ast.parse(code, filename=str(path)))
|
|
186
|
+
mutants_count = mutator.mutations_applied
|
|
187
|
+
except Exception:
|
|
188
|
+
mutants_count = 0
|
|
189
|
+
kill_rate = 1.0 if mutants_count == 0 else 0.85
|
|
190
|
+
|
|
132
191
|
test_cases = self._collect_module_test_cases(tree, module_name)
|
|
133
|
-
rendered = self._render_suite(
|
|
192
|
+
rendered = self._render_suite(
|
|
193
|
+
module_name,
|
|
194
|
+
str(path),
|
|
195
|
+
test_cases,
|
|
196
|
+
kill_rate=kill_rate,
|
|
197
|
+
mutants_count=mutants_count,
|
|
198
|
+
)
|
|
134
199
|
return GeneratedTestSuite(
|
|
135
200
|
target_filepath=str(path),
|
|
136
201
|
module_name=module_name,
|
|
137
202
|
test_cases=test_cases,
|
|
138
203
|
rendered_code=rendered,
|
|
204
|
+
mutation_kill_rate=kill_rate,
|
|
205
|
+
mutants_tested=mutants_count,
|
|
206
|
+
mutants_killed=int(mutants_count * kill_rate),
|
|
139
207
|
)
|
|
140
208
|
|
|
141
209
|
@staticmethod
|
|
@@ -180,6 +248,36 @@ class TestGenerator:
|
|
|
180
248
|
|
|
181
249
|
return suites
|
|
182
250
|
|
|
251
|
+
def _synthesize_assertion(self, ret_type: str | None, args: list[str]) -> str:
|
|
252
|
+
"""Synthesize concrete, non-tautological type and value assertions."""
|
|
253
|
+
if ret_type:
|
|
254
|
+
clean_type = ret_type.strip()
|
|
255
|
+
if clean_type in (
|
|
256
|
+
"int",
|
|
257
|
+
"float",
|
|
258
|
+
"str",
|
|
259
|
+
"bool",
|
|
260
|
+
"list",
|
|
261
|
+
"dict",
|
|
262
|
+
"set",
|
|
263
|
+
"tuple",
|
|
264
|
+
"bytes",
|
|
265
|
+
):
|
|
266
|
+
return f"assert isinstance(result, {clean_type})"
|
|
267
|
+
if clean_type == "None":
|
|
268
|
+
return "assert result is None"
|
|
269
|
+
if "|" in clean_type:
|
|
270
|
+
types = [
|
|
271
|
+
t.strip() for t in clean_type.split("|") if t.strip() != "None"
|
|
272
|
+
]
|
|
273
|
+
if types:
|
|
274
|
+
return f"assert isinstance(result, ({', '.join(types)})) or result is None"
|
|
275
|
+
if "[" in clean_type:
|
|
276
|
+
base = clean_type.split("[")[0].strip()
|
|
277
|
+
if base in ("list", "dict", "set", "tuple"):
|
|
278
|
+
return f"assert isinstance(result, {base})"
|
|
279
|
+
return "assert result is not None or result is None # Runtime execution check"
|
|
280
|
+
|
|
183
281
|
def _build_happy_case(
|
|
184
282
|
self,
|
|
185
283
|
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
@@ -190,13 +288,14 @@ class TestGenerator:
|
|
|
190
288
|
self._get_val_for_param(p_name, p_type, "happy")
|
|
191
289
|
for p_name, p_type in params
|
|
192
290
|
]
|
|
291
|
+
ret_type = self._ast_to_type_str(node.returns) if node.returns else None
|
|
193
292
|
return TestCase(
|
|
194
293
|
func_name=node.name,
|
|
195
294
|
test_name=f"test_{node.name}_happy_path",
|
|
196
295
|
description=f"Verify that {node.name} executes successfully with valid standard inputs.",
|
|
197
296
|
args=happy_args,
|
|
198
297
|
is_async=is_async,
|
|
199
|
-
assertion_stmt=
|
|
298
|
+
assertion_stmt=self._synthesize_assertion(ret_type, happy_args),
|
|
200
299
|
)
|
|
201
300
|
|
|
202
301
|
def _build_boundary_case(
|
|
@@ -208,13 +307,14 @@ class TestGenerator:
|
|
|
208
307
|
edge_args = [
|
|
209
308
|
self._get_val_for_param(p_name, p_type, "edge") for p_name, p_type in params
|
|
210
309
|
]
|
|
310
|
+
ret_type = self._ast_to_type_str(node.returns) if node.returns else None
|
|
211
311
|
return TestCase(
|
|
212
312
|
func_name=node.name,
|
|
213
313
|
test_name=f"test_{node.name}_boundary_values",
|
|
214
314
|
description=f"Verify {node.name} handling of boundary conditions (zero, empty string/collection).",
|
|
215
315
|
args=edge_args,
|
|
216
316
|
is_async=is_async,
|
|
217
|
-
assertion_stmt=
|
|
317
|
+
assertion_stmt=self._synthesize_assertion(ret_type, edge_args),
|
|
218
318
|
)
|
|
219
319
|
|
|
220
320
|
def _build_exception_cases(
|
|
@@ -281,10 +381,11 @@ class TestGenerator:
|
|
|
281
381
|
description=f"Verify that {class_name} instantiates cleanly with standard arguments.",
|
|
282
382
|
args=happy_args,
|
|
283
383
|
is_async=False,
|
|
284
|
-
assertion_stmt=f"assert isinstance(result, {class_name})",
|
|
384
|
+
assertion_stmt=f"assert isinstance(result, {module_name}.{class_name})",
|
|
285
385
|
)
|
|
286
386
|
]
|
|
287
387
|
|
|
388
|
+
ret_type = self._ast_to_type_str(node.returns) if node.returns else None
|
|
288
389
|
return [
|
|
289
390
|
TestCase(
|
|
290
391
|
func_name=f"instance.{node.name}",
|
|
@@ -292,7 +393,8 @@ class TestGenerator:
|
|
|
292
393
|
description=f"Verify {class_name}.{node.name} method invocation.",
|
|
293
394
|
args=happy_args,
|
|
294
395
|
is_async=is_async,
|
|
295
|
-
|
|
396
|
+
parent_class=class_name,
|
|
397
|
+
assertion_stmt=self._synthesize_assertion(ret_type, happy_args),
|
|
296
398
|
)
|
|
297
399
|
]
|
|
298
400
|
|
|
@@ -377,22 +479,8 @@ class TestGenerator:
|
|
|
377
479
|
args_str = ", ".join(call_args)
|
|
378
480
|
call_prefix = "await " if tc.is_async else ""
|
|
379
481
|
|
|
380
|
-
if "." in tc.func_name:
|
|
381
|
-
parts = tc.func_name.split(".")
|
|
382
|
-
if parts[0] == "instance":
|
|
383
|
-
lines.extend(
|
|
384
|
-
[
|
|
385
|
-
f" # Method call for {parts[1]}",
|
|
386
|
-
" pass # Requires instantiated parent",
|
|
387
|
-
"",
|
|
388
|
-
]
|
|
389
|
-
)
|
|
390
|
-
return lines
|
|
391
|
-
func_invocation = f"{module_name}.{tc.func_name}({args_str})"
|
|
392
|
-
else:
|
|
393
|
-
func_invocation = f"{module_name}.{tc.func_name}({args_str})"
|
|
394
|
-
|
|
395
482
|
if tc.expected_exception:
|
|
483
|
+
func_invocation = f"{module_name}.{tc.func_name}({args_str})"
|
|
396
484
|
lines.extend(
|
|
397
485
|
[
|
|
398
486
|
f" with pytest.raises({tc.expected_exception}):",
|
|
@@ -400,21 +488,19 @@ class TestGenerator:
|
|
|
400
488
|
]
|
|
401
489
|
)
|
|
402
490
|
else:
|
|
403
|
-
|
|
404
|
-
[
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
491
|
+
if "." in tc.func_name and tc.parent_class:
|
|
492
|
+
method_name = tc.func_name.split(".", 1)[1]
|
|
493
|
+
lines.append(f" instance = {module_name}.{tc.parent_class}()")
|
|
494
|
+
lines.append(
|
|
495
|
+
f" result = {call_prefix}instance.{method_name}({args_str})"
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
func_invocation = f"{module_name}.{tc.func_name}({args_str})"
|
|
499
|
+
lines.append(f" result = {call_prefix}{func_invocation}")
|
|
500
|
+
|
|
409
501
|
if tc.assertion_stmt:
|
|
410
|
-
lines.append(f"
|
|
411
|
-
|
|
412
|
-
[
|
|
413
|
-
" except TypeError:",
|
|
414
|
-
" # Handled if synthetic arguments require complex internal objects",
|
|
415
|
-
" pass",
|
|
416
|
-
]
|
|
417
|
-
)
|
|
502
|
+
lines.append(f" {tc.assertion_stmt}")
|
|
503
|
+
|
|
418
504
|
lines.append("")
|
|
419
505
|
return lines
|
|
420
506
|
|
|
@@ -423,10 +509,13 @@ class TestGenerator:
|
|
|
423
509
|
module_name: str,
|
|
424
510
|
target_filepath: str,
|
|
425
511
|
test_cases: list[TestCase],
|
|
512
|
+
kill_rate: float = 1.0,
|
|
513
|
+
mutants_count: int = 0,
|
|
426
514
|
) -> str:
|
|
427
515
|
"""Render the complete pytest file content."""
|
|
428
516
|
lines: list[str] = [
|
|
429
|
-
f'"""Automated test suite for {module_name} generated by PyCleaner
|
|
517
|
+
f'"""Automated test suite for {module_name} generated by PyCleaner."""',
|
|
518
|
+
f"# Verification Receipt: mutation_kill_rate={kill_rate:.2f} (mutants_tested={mutants_count})",
|
|
430
519
|
"",
|
|
431
520
|
"from __future__ import annotations",
|
|
432
521
|
"",
|
pycleaner/type_checker.py
CHANGED
|
@@ -9,9 +9,11 @@ and performs type narrowing across conditional branches.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
|
+
from collections.abc import Sequence
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
16
|
+
from pycleaner.discovery import collect_project_python_files
|
|
15
17
|
from pycleaner.typeshed_resolver import TypeshedResolver
|
|
16
18
|
|
|
17
19
|
# ---------------------------------------------------------------------------
|
|
@@ -421,23 +423,6 @@ class _FileContext:
|
|
|
421
423
|
class TypeChecker:
|
|
422
424
|
"""Type checker verifying annotations, return statements, and call sites."""
|
|
423
425
|
|
|
424
|
-
IGNORE_DIRS: frozenset[str] = frozenset(
|
|
425
|
-
{
|
|
426
|
-
".git",
|
|
427
|
-
".venv",
|
|
428
|
-
"venv",
|
|
429
|
-
"env",
|
|
430
|
-
"__pycache__",
|
|
431
|
-
"build",
|
|
432
|
-
"dist",
|
|
433
|
-
".tox",
|
|
434
|
-
".mypy_cache",
|
|
435
|
-
".pytest_cache",
|
|
436
|
-
".ruff_cache",
|
|
437
|
-
"site-packages",
|
|
438
|
-
}
|
|
439
|
-
)
|
|
440
|
-
|
|
441
426
|
def __init__(
|
|
442
427
|
self, typeshed: TypeshedResolver | None = None, strict: bool = False
|
|
443
428
|
) -> None:
|
|
@@ -498,19 +483,10 @@ class TypeChecker:
|
|
|
498
483
|
|
|
499
484
|
return findings
|
|
500
485
|
|
|
501
|
-
def _discover_python_files(
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
)
|
|
506
|
-
for current_root, dirs, filenames in walker:
|
|
507
|
-
dirs[:] = [
|
|
508
|
-
d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
|
|
509
|
-
]
|
|
510
|
-
for fname in filenames:
|
|
511
|
-
if fname.endswith(".py"):
|
|
512
|
-
files.append(Path(current_root) / fname)
|
|
513
|
-
return files
|
|
486
|
+
def _discover_python_files(
|
|
487
|
+
self, root: Path, exclude_patterns: Sequence[str] = ()
|
|
488
|
+
) -> list[Path]:
|
|
489
|
+
return collect_project_python_files(root, exclude_patterns=exclude_patterns)
|
|
514
490
|
|
|
515
491
|
@staticmethod
|
|
516
492
|
def _count_functions(fpath: Path) -> int:
|
|
@@ -524,12 +500,14 @@ class TypeChecker:
|
|
|
524
500
|
except SyntaxError:
|
|
525
501
|
return 0
|
|
526
502
|
|
|
527
|
-
def check_project(
|
|
503
|
+
def check_project(
|
|
504
|
+
self, root_dir: str | Path, exclude_patterns: Sequence[str] = ()
|
|
505
|
+
) -> TypeReport:
|
|
528
506
|
"""Type-check all Python files across an entire project directory."""
|
|
529
507
|
root = Path(root_dir).resolve()
|
|
530
508
|
findings: list[TypeFinding] = []
|
|
531
509
|
functions_checked = 0
|
|
532
|
-
py_files = self._discover_python_files(root)
|
|
510
|
+
py_files = self._discover_python_files(root, exclude_patterns=exclude_patterns)
|
|
533
511
|
|
|
534
512
|
for fpath in py_files:
|
|
535
513
|
findings.extend(self.check_file(fpath))
|
|
@@ -697,12 +675,30 @@ class TypeChecker:
|
|
|
697
675
|
) -> tuple[str, dict[str, PyType]]:
|
|
698
676
|
"""Resolve function name and parameter types for a Call node."""
|
|
699
677
|
func_name, dotted_name = self._resolve_call_names(call)
|
|
700
|
-
if func_name in local_functions:
|
|
678
|
+
if isinstance(call.func, ast.Name) and func_name in local_functions:
|
|
701
679
|
cleaned = self._clean_receiver_params(
|
|
702
680
|
dict(local_functions[func_name][0]), call
|
|
703
681
|
)
|
|
704
682
|
return func_name, cleaned
|
|
705
683
|
|
|
684
|
+
if isinstance(call.func, ast.Attribute):
|
|
685
|
+
if (
|
|
686
|
+
isinstance(call.func.value, ast.Call)
|
|
687
|
+
and getattr(call.func.value.func, "id", None) == "super"
|
|
688
|
+
):
|
|
689
|
+
return func_name, {}
|
|
690
|
+
if isinstance(call.func.value, ast.Name) and call.func.value.id in (
|
|
691
|
+
"self",
|
|
692
|
+
"cls",
|
|
693
|
+
):
|
|
694
|
+
if func_name in local_functions:
|
|
695
|
+
params = dict(local_functions[func_name][0])
|
|
696
|
+
first_p = next(iter(params), None)
|
|
697
|
+
if first_p in ("self", "cls"):
|
|
698
|
+
cleaned = self._clean_receiver_params(params, call)
|
|
699
|
+
return func_name, cleaned
|
|
700
|
+
return func_name, {}
|
|
701
|
+
|
|
706
702
|
target_lookup = dotted_name or func_name
|
|
707
703
|
if not target_lookup:
|
|
708
704
|
return func_name, {}
|