ballpython 2.1.0__py3-none-any.whl → 2.1.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-2.1.0.dist-info → ballpython-2.1.2.dist-info}/METADATA +1 -3
- ballpython-2.1.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +1 -1
- pycleaner/cli.py +202 -36
- pycleaner/complexity_analyzer.py +11 -8
- pycleaner/dead_code_detector.py +156 -43
- pycleaner/dependency_auditor.py +26 -5
- pycleaner/discovery.py +17 -0
- pycleaner/frameworks/__init__.py +8 -1
- pycleaner/linter_formatter.py +23 -4
- pycleaner/pipeline.py +99 -11
- pycleaner/security_scanner.py +149 -14
- pycleaner/syntax_healer.py +208 -43
- pycleaner/taint_engine.py +19 -15
- pycleaner/type_checker.py +128 -25
- pycleaner/verifier.py +12 -4
- ballpython-2.1.0.dist-info/RECORD +0 -34
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/WHEEL +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/top_level.txt +0 -0
pycleaner/syntax_healer.py
CHANGED
|
@@ -146,53 +146,95 @@ class SyntaxHealer:
|
|
|
146
146
|
code = source
|
|
147
147
|
repairs: list[str] = []
|
|
148
148
|
|
|
149
|
-
def apply_step(fn: Any, msg_fmt: str) ->
|
|
149
|
+
def apply_step(fn: Any, msg_fmt: str) -> None:
|
|
150
150
|
nonlocal code
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
try:
|
|
152
|
+
prev_err = self._check_syntax(code, filename)
|
|
153
|
+
new_code, count = fn(code)
|
|
154
|
+
if count > 0 and new_code != code:
|
|
155
|
+
new_err = self._check_syntax(new_code, filename)
|
|
156
|
+
# If previously valid code was broken by this repair pass, roll back:
|
|
157
|
+
if prev_err is None and new_err is not None:
|
|
158
|
+
logging.getLogger(__name__).warning(
|
|
159
|
+
"Healing step %s introduced a syntax error; rolling back",
|
|
160
|
+
getattr(fn, "__name__", str(fn)),
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
if prev_err is not None and new_err is not None:
|
|
164
|
+
if "::" in new_code and "::" not in code:
|
|
165
|
+
cleaned = re.sub(r"(?<!:)::(?!:)", ":", new_code)
|
|
166
|
+
c_err = self._check_syntax(cleaned, filename)
|
|
167
|
+
if c_err is None or c_err != new_err:
|
|
168
|
+
new_code = cleaned
|
|
169
|
+
new_err = c_err
|
|
170
|
+
if prev_err is not None and new_err is not None:
|
|
171
|
+
if new_err[1] < prev_err[1]:
|
|
172
|
+
logging.getLogger(__name__).warning(
|
|
173
|
+
"Healing step %s broke earlier line %d (previous error was line %d); rolling back",
|
|
174
|
+
getattr(fn, "__name__", str(fn)),
|
|
175
|
+
new_err[1],
|
|
176
|
+
prev_err[1],
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
code = new_code
|
|
180
|
+
repairs.append(msg_fmt.format(count=count))
|
|
181
|
+
except Exception:
|
|
182
|
+
logging.getLogger(__name__).debug(
|
|
183
|
+
"Suppressed exception in syntax healing step", exc_info=True
|
|
184
|
+
)
|
|
157
185
|
|
|
158
186
|
if "\t" in code:
|
|
159
187
|
code = code.expandtabs(4)
|
|
160
188
|
repairs.append("Normalized tab characters to 4 spaces")
|
|
161
|
-
if self._check_syntax(code, filename) is None:
|
|
162
|
-
return code, repairs
|
|
163
189
|
|
|
164
190
|
if self.fix_py2_syntax:
|
|
165
|
-
|
|
191
|
+
apply_step(
|
|
166
192
|
self._fix_py2_except,
|
|
167
193
|
"Converted {count} legacy Python 2 except clause(s) to 'as'",
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
if apply_step(
|
|
194
|
+
)
|
|
195
|
+
apply_step(
|
|
171
196
|
self._fix_py2_print,
|
|
172
197
|
"Converted {count} legacy print statement(s) to print() calls",
|
|
173
|
-
)
|
|
174
|
-
return code, repairs
|
|
198
|
+
)
|
|
175
199
|
|
|
176
|
-
if self.fix_conditional_assignments
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
200
|
+
if self.fix_conditional_assignments:
|
|
201
|
+
apply_step(
|
|
202
|
+
self._fix_conditional_assignments,
|
|
203
|
+
"Replaced {count} accidental '=' assignment(s) with '==' in conditionals",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
apply_step(
|
|
207
|
+
self._fix_double_colons,
|
|
208
|
+
"Normalized {count} duplicate colon(s) on compound statement header(s)",
|
|
209
|
+
)
|
|
181
210
|
|
|
182
|
-
|
|
211
|
+
apply_step(
|
|
183
212
|
self._fix_missing_colons,
|
|
184
213
|
"Appended missing ':' to {count} compound statement header(s)",
|
|
185
|
-
)
|
|
186
|
-
return code, repairs
|
|
214
|
+
)
|
|
187
215
|
|
|
188
|
-
|
|
216
|
+
apply_step(
|
|
189
217
|
self._fix_unindented_blocks,
|
|
190
218
|
"Re-indented {count} under-indented block(s) following compound statement headers",
|
|
191
|
-
)
|
|
192
|
-
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
prev_err = self._check_syntax(code, filename)
|
|
223
|
+
d_code, d_repairs = self._fix_unbalanced_delimiters(code, filename=filename)
|
|
224
|
+
if d_code != code:
|
|
225
|
+
d_err = self._check_syntax(d_code, filename)
|
|
226
|
+
if prev_err is None and d_err is not None:
|
|
227
|
+
logging.getLogger(__name__).warning(
|
|
228
|
+
"Delimiter healing broke valid syntax; rolling back"
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
code = d_code
|
|
232
|
+
repairs.extend(d_repairs)
|
|
233
|
+
except Exception:
|
|
234
|
+
logging.getLogger(__name__).debug(
|
|
235
|
+
"Suppressed exception closing delimiters", exc_info=True
|
|
236
|
+
)
|
|
193
237
|
|
|
194
|
-
code, d_repairs = self._fix_unbalanced_delimiters(code, filename=filename)
|
|
195
|
-
repairs.extend(d_repairs)
|
|
196
238
|
return code, repairs
|
|
197
239
|
|
|
198
240
|
def heal(self, source: str, filename: str = "<unknown>") -> SyntaxHealResult:
|
|
@@ -497,11 +539,16 @@ class SyntaxHealer:
|
|
|
497
539
|
logical_tokens: list[tokenize.TokenInfo] = []
|
|
498
540
|
|
|
499
541
|
def flush() -> None:
|
|
542
|
+
if not logical_tokens:
|
|
543
|
+
return
|
|
500
544
|
header_idx = self._get_compound_header_idx(logical_tokens)
|
|
501
545
|
if header_idx is None:
|
|
502
546
|
return
|
|
503
547
|
if self._has_colon_at_depth_zero(logical_tokens, header_idx + 1):
|
|
504
548
|
return
|
|
549
|
+
# If the last token is already a colon, do not append another one
|
|
550
|
+
if logical_tokens[-1].string == ":":
|
|
551
|
+
return
|
|
505
552
|
last = logical_tokens[-1]
|
|
506
553
|
insertions.append((last.end[0] - 1, last.end[1]))
|
|
507
554
|
|
|
@@ -524,28 +571,146 @@ class SyntaxHealer:
|
|
|
524
571
|
flush()
|
|
525
572
|
return insertions
|
|
526
573
|
|
|
574
|
+
_DOUBLE_COLON_RE = re.compile(
|
|
575
|
+
r"^([ \t]*(?:async\s+)?(?:def\s+[a-zA-Z_]\w*|class\s+[a-zA-Z_]\w*|if\b|elif\b|else\b|for\b|while\b|try\b|except\b|finally\b|with\b)[^\n#]*?):{2,}(\s*(?:#.*)?)$"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def _fix_double_colons(self, code: str) -> tuple[str, int]:
|
|
579
|
+
"""Normalize multiple consecutive colons on compound statement headers."""
|
|
580
|
+
if "::" not in code:
|
|
581
|
+
return code, 0
|
|
582
|
+
lines = code.splitlines(keepends=True)
|
|
583
|
+
count = 0
|
|
584
|
+
new_lines: list[str] = []
|
|
585
|
+
for line in lines:
|
|
586
|
+
m = self._DOUBLE_COLON_RE.match(line.rstrip("\r\n"))
|
|
587
|
+
if m:
|
|
588
|
+
header = m.group(1).rstrip()
|
|
589
|
+
suffix = m.group(2)
|
|
590
|
+
ending = (
|
|
591
|
+
"\r\n"
|
|
592
|
+
if line.endswith("\r\n")
|
|
593
|
+
else ("\n" if line.endswith("\n") else "")
|
|
594
|
+
)
|
|
595
|
+
new_lines.append(f"{header}:{suffix}{ending}")
|
|
596
|
+
count += 1
|
|
597
|
+
else:
|
|
598
|
+
new_lines.append(line)
|
|
599
|
+
return "".join(new_lines), count
|
|
600
|
+
|
|
601
|
+
_COMPOUND_HEADER_FALLBACK = re.compile(
|
|
602
|
+
r"^([ \t]*(?:async\s+)?(?:def\s+[a-zA-Z_]\w*|class\s+[a-zA-Z_]\w*|if\b|elif\b|else\b|for\b|while\b|try\b|except\b|finally\b|with\b)[^\n#]*?)(?<!:)(\s*(?:#.*)?)$"
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _fix_missing_colons_fallback(self, code: str) -> tuple[str, int]:
|
|
606
|
+
"""Line-by-line regex fallback to append missing colons to compound statements."""
|
|
607
|
+
lines = code.splitlines(keepends=True)
|
|
608
|
+
count = 0
|
|
609
|
+
new_lines: list[str] = []
|
|
610
|
+
for line in lines:
|
|
611
|
+
stripped = line.strip()
|
|
612
|
+
if not stripped or stripped.startswith("#"):
|
|
613
|
+
new_lines.append(line)
|
|
614
|
+
continue
|
|
615
|
+
|
|
616
|
+
# If the non-comment portion of the line already ends with ':', skip it
|
|
617
|
+
clean_line = re.sub(r"#.*$", "", line.rstrip("\r\n")).rstrip()
|
|
618
|
+
if clean_line.endswith(":"):
|
|
619
|
+
new_lines.append(line)
|
|
620
|
+
continue
|
|
621
|
+
|
|
622
|
+
m = self._COMPOUND_HEADER_FALLBACK.match(line.rstrip("\r\n"))
|
|
623
|
+
if m:
|
|
624
|
+
header = m.group(1).rstrip()
|
|
625
|
+
# Before appending ':', check that the header does not already end with ':'
|
|
626
|
+
if header.endswith(":"):
|
|
627
|
+
new_lines.append(line)
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
# If this line already has a colon at depth 0 (e.g. one-liner: if cond: return x, or else: return 0), skip it!
|
|
631
|
+
depth = 0
|
|
632
|
+
has_colon_at_depth_0 = False
|
|
633
|
+
in_quote = False
|
|
634
|
+
quote_char = ""
|
|
635
|
+
for char in header:
|
|
636
|
+
if in_quote:
|
|
637
|
+
if char == quote_char:
|
|
638
|
+
in_quote = False
|
|
639
|
+
elif char in ("'", '"'):
|
|
640
|
+
in_quote = True
|
|
641
|
+
quote_char = char
|
|
642
|
+
elif char in "([{":
|
|
643
|
+
depth += 1
|
|
644
|
+
elif char in ")]}":
|
|
645
|
+
depth = max(0, depth - 1)
|
|
646
|
+
elif char == ":" and depth == 0:
|
|
647
|
+
has_colon_at_depth_0 = True
|
|
648
|
+
break
|
|
649
|
+
|
|
650
|
+
if has_colon_at_depth_0:
|
|
651
|
+
new_lines.append(line)
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
# If brackets on this line are unbalanced (e.g. def foo(\n), skip appending colon to this line
|
|
655
|
+
open_cnt = sum(header.count(c) for c in "([{")
|
|
656
|
+
close_cnt = sum(header.count(c) for c in ")]}")
|
|
657
|
+
if open_cnt > close_cnt:
|
|
658
|
+
new_lines.append(line)
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
suffix = m.group(2)
|
|
662
|
+
ending = (
|
|
663
|
+
"\r\n"
|
|
664
|
+
if line.endswith("\r\n")
|
|
665
|
+
else ("\n" if line.endswith("\n") else "")
|
|
666
|
+
)
|
|
667
|
+
new_lines.append(f"{header}:{suffix}{ending}")
|
|
668
|
+
count += 1
|
|
669
|
+
else:
|
|
670
|
+
new_lines.append(line)
|
|
671
|
+
return "".join(new_lines), count
|
|
672
|
+
|
|
527
673
|
def _fix_missing_colons(self, code: str) -> tuple[str, int]:
|
|
528
674
|
"""Detect compound statement headers without trailing colons and append them."""
|
|
529
|
-
tokens: list[tokenize.TokenInfo] | None
|
|
675
|
+
tokens: list[tokenize.TokenInfo] | None = None
|
|
530
676
|
try:
|
|
531
677
|
tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
|
|
532
678
|
except tokenize.TokenError:
|
|
533
679
|
tokens = self._tokenize_with_recovered_delimiters(code)
|
|
534
|
-
if tokens is None:
|
|
535
|
-
return code, 0
|
|
536
|
-
|
|
537
|
-
insertions = self._find_missing_colon_insertions(tokens)
|
|
538
|
-
if not insertions:
|
|
539
|
-
return code, 0
|
|
540
|
-
|
|
541
|
-
lines = code.splitlines(keepends=True)
|
|
542
|
-
insertions.sort(key=lambda pos: (pos[0], pos[1]), reverse=True)
|
|
543
|
-
for line_idx, col in insertions:
|
|
544
|
-
if 0 <= line_idx < len(lines):
|
|
545
|
-
target = lines[line_idx]
|
|
546
|
-
lines[line_idx] = target[:col] + ":" + target[col:]
|
|
547
680
|
|
|
548
|
-
|
|
681
|
+
cur_code = code
|
|
682
|
+
total_count = 0
|
|
683
|
+
|
|
684
|
+
if tokens is not None:
|
|
685
|
+
insertions = self._find_missing_colon_insertions(tokens)
|
|
686
|
+
if insertions:
|
|
687
|
+
lines = cur_code.splitlines(keepends=True)
|
|
688
|
+
insertions.sort(key=lambda pos: (pos[0], pos[1]), reverse=True)
|
|
689
|
+
applied_insertions = 0
|
|
690
|
+
for line_idx, col in insertions:
|
|
691
|
+
if 0 <= line_idx < len(lines):
|
|
692
|
+
target = lines[line_idx]
|
|
693
|
+
before = target[:col].rstrip()
|
|
694
|
+
after = target[col:].lstrip()
|
|
695
|
+
if before.endswith(":") or after.startswith(":"):
|
|
696
|
+
continue
|
|
697
|
+
clean_line = re.sub(r"#.*$", "", target).rstrip()
|
|
698
|
+
if clean_line.endswith(":"):
|
|
699
|
+
continue
|
|
700
|
+
lines[line_idx] = target[:col] + ":" + target[col:]
|
|
701
|
+
applied_insertions += 1
|
|
702
|
+
cur_code = "".join(lines)
|
|
703
|
+
total_count += applied_insertions
|
|
704
|
+
|
|
705
|
+
# Also run fallback to catch any compound headers missed (e.g. disrupted by unclosed delimiters)
|
|
706
|
+
cur_code, fb_count = self._fix_missing_colons_fallback(cur_code)
|
|
707
|
+
total_count += fb_count
|
|
708
|
+
|
|
709
|
+
# Clean up any accidental double colons created on compound headers
|
|
710
|
+
if "::" in cur_code and "::" not in code:
|
|
711
|
+
cur_code = re.sub(r"(?<!:)::(?!:)", ":", cur_code)
|
|
712
|
+
|
|
713
|
+
return cur_code, total_count
|
|
549
714
|
|
|
550
715
|
def _tokenize_with_recovered_delimiters(
|
|
551
716
|
self, code: str
|
pycleaner/taint_engine.py
CHANGED
|
@@ -262,20 +262,8 @@ class TaintEngine:
|
|
|
262
262
|
def __init__(self) -> None:
|
|
263
263
|
self.function_summaries: dict[str, FunctionTaintSummary] = {}
|
|
264
264
|
|
|
265
|
-
def
|
|
266
|
-
|
|
267
|
-
) -> TaintReport:
|
|
268
|
-
"""Scan a file or directory for dataflow taint vulnerabilities."""
|
|
269
|
-
path = Path(target)
|
|
270
|
-
if path.is_file():
|
|
271
|
-
files = [path] if path.suffix == ".py" else []
|
|
272
|
-
elif path.is_dir():
|
|
273
|
-
files = collect_project_python_files(
|
|
274
|
-
path, exclude_patterns=exclude_patterns
|
|
275
|
-
)
|
|
276
|
-
else:
|
|
277
|
-
return TaintReport()
|
|
278
|
-
|
|
265
|
+
def scan_files(self, files: Sequence[Path]) -> TaintReport:
|
|
266
|
+
"""Scan a specific sequence of Python files for dataflow taint vulnerabilities."""
|
|
279
267
|
report = TaintReport()
|
|
280
268
|
parsed_files: list[tuple[Path, ast.Module]] = []
|
|
281
269
|
|
|
@@ -286,7 +274,7 @@ class TaintEngine:
|
|
|
286
274
|
tree = ast.parse(code, filename=str(fpath))
|
|
287
275
|
parsed_files.append((fpath, tree))
|
|
288
276
|
self._collect_function_summaries(str(fpath), tree)
|
|
289
|
-
except SyntaxError:
|
|
277
|
+
except (SyntaxError, OSError):
|
|
290
278
|
continue
|
|
291
279
|
|
|
292
280
|
# Pass 2: Interprocedural and intraprocedural taint flow analysis
|
|
@@ -299,6 +287,22 @@ class TaintEngine:
|
|
|
299
287
|
|
|
300
288
|
return report
|
|
301
289
|
|
|
290
|
+
def scan_path(
|
|
291
|
+
self, target: str | Path, exclude_patterns: Sequence[str] = ()
|
|
292
|
+
) -> TaintReport:
|
|
293
|
+
"""Scan a file or directory for dataflow taint vulnerabilities."""
|
|
294
|
+
path = Path(target)
|
|
295
|
+
if path.is_file():
|
|
296
|
+
files = [path] if path.suffix == ".py" else []
|
|
297
|
+
elif path.is_dir():
|
|
298
|
+
files = collect_project_python_files(
|
|
299
|
+
path, exclude_patterns=exclude_patterns
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
return TaintReport()
|
|
303
|
+
|
|
304
|
+
return self.scan_files(files)
|
|
305
|
+
|
|
302
306
|
def _inspect_return_child(
|
|
303
307
|
self, child: ast.AST, summary: FunctionTaintSummary
|
|
304
308
|
) -> None:
|
pycleaner/type_checker.py
CHANGED
|
@@ -32,6 +32,8 @@ class PyType:
|
|
|
32
32
|
"""Check if this type can be assigned to target type."""
|
|
33
33
|
if isinstance(target, AnyType) or isinstance(self, AnyType) or target == self:
|
|
34
34
|
return True
|
|
35
|
+
if getattr(target, "name", None) == "object":
|
|
36
|
+
return True
|
|
35
37
|
if (
|
|
36
38
|
isinstance(self, CustomClassType)
|
|
37
39
|
and isinstance(target, CustomClassType)
|
|
@@ -116,7 +118,8 @@ class PyType:
|
|
|
116
118
|
)
|
|
117
119
|
|
|
118
120
|
def _check_custom_source(self, target: PyType) -> bool:
|
|
119
|
-
|
|
121
|
+
src_name = getattr(self, "name", "")
|
|
122
|
+
if src_name in ("StrPath", "PathLike", "AnyStr"):
|
|
120
123
|
if isinstance(target, PrimitiveType) and target.name in ("str", "bytes"):
|
|
121
124
|
return True
|
|
122
125
|
if isinstance(target, CustomClassType) and target.name in (
|
|
@@ -127,6 +130,31 @@ class PyType:
|
|
|
127
130
|
"bytes",
|
|
128
131
|
):
|
|
129
132
|
return True
|
|
133
|
+
if (
|
|
134
|
+
src_name.startswith("Supports")
|
|
135
|
+
or src_name.startswith("_")
|
|
136
|
+
or src_name in ("T", "Any")
|
|
137
|
+
):
|
|
138
|
+
return True
|
|
139
|
+
if src_name in (
|
|
140
|
+
"zip",
|
|
141
|
+
"enumerate",
|
|
142
|
+
"range",
|
|
143
|
+
"generator",
|
|
144
|
+
"iter",
|
|
145
|
+
"map",
|
|
146
|
+
"filter",
|
|
147
|
+
):
|
|
148
|
+
if isinstance(target, (ListType, SetType)):
|
|
149
|
+
return True
|
|
150
|
+
if isinstance(target, CustomClassType) and target.name in (
|
|
151
|
+
"list",
|
|
152
|
+
"List",
|
|
153
|
+
"Sequence",
|
|
154
|
+
"Iterable",
|
|
155
|
+
"Collection",
|
|
156
|
+
):
|
|
157
|
+
return True
|
|
130
158
|
return False
|
|
131
159
|
|
|
132
160
|
def _check_numeric_promotions(self, target: PyType) -> bool:
|
|
@@ -556,25 +584,33 @@ class TypeChecker:
|
|
|
556
584
|
except SyntaxError:
|
|
557
585
|
return 0
|
|
558
586
|
|
|
559
|
-
def
|
|
560
|
-
|
|
561
|
-
) -> TypeReport:
|
|
562
|
-
"""Type-check all Python files across an entire project directory."""
|
|
563
|
-
root = Path(root_dir).resolve()
|
|
587
|
+
def check_files(self, target_files: Sequence[Path]) -> TypeReport:
|
|
588
|
+
"""Type-check a specific sequence of Python files without walking directories."""
|
|
564
589
|
findings: list[TypeFinding] = []
|
|
565
590
|
functions_checked = 0
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
591
|
+
scanned_count = 0
|
|
592
|
+
for fpath in target_files:
|
|
593
|
+
try:
|
|
594
|
+
findings.extend(self.check_file(fpath))
|
|
595
|
+
functions_checked += self._count_functions(fpath)
|
|
596
|
+
scanned_count += 1
|
|
597
|
+
except OSError:
|
|
598
|
+
continue
|
|
571
599
|
|
|
572
600
|
return TypeReport(
|
|
573
601
|
findings=findings,
|
|
574
|
-
files_scanned=
|
|
602
|
+
files_scanned=scanned_count,
|
|
575
603
|
functions_checked=functions_checked,
|
|
576
604
|
)
|
|
577
605
|
|
|
606
|
+
def check_project(
|
|
607
|
+
self, root_dir: str | Path, exclude_patterns: Sequence[str] = ()
|
|
608
|
+
) -> TypeReport:
|
|
609
|
+
"""Type-check all Python files across an entire project directory."""
|
|
610
|
+
root = Path(root_dir).resolve()
|
|
611
|
+
py_files = self._discover_python_files(root, exclude_patterns=exclude_patterns)
|
|
612
|
+
return self.check_files(py_files)
|
|
613
|
+
|
|
578
614
|
def _fallback_walk(self, root: Path):
|
|
579
615
|
import os
|
|
580
616
|
|
|
@@ -593,6 +629,58 @@ class TypeChecker:
|
|
|
593
629
|
)
|
|
594
630
|
return scope
|
|
595
631
|
|
|
632
|
+
@staticmethod
|
|
633
|
+
def _is_terminal_statement(stmt: ast.stmt | None) -> bool:
|
|
634
|
+
"""Check if an AST statement terminates all control flow paths (returns or raises)."""
|
|
635
|
+
if stmt is None:
|
|
636
|
+
return False
|
|
637
|
+
if isinstance(stmt, (ast.Return, ast.Raise)):
|
|
638
|
+
return True
|
|
639
|
+
if isinstance(stmt, (ast.With, ast.AsyncWith)):
|
|
640
|
+
return bool(stmt.body) and TypeChecker._is_terminal_statement(stmt.body[-1])
|
|
641
|
+
if isinstance(stmt, ast.Try):
|
|
642
|
+
if not (
|
|
643
|
+
bool(stmt.body) and TypeChecker._is_terminal_statement(stmt.body[-1])
|
|
644
|
+
):
|
|
645
|
+
return False
|
|
646
|
+
for handler in stmt.handlers:
|
|
647
|
+
if not (
|
|
648
|
+
bool(handler.body)
|
|
649
|
+
and TypeChecker._is_terminal_statement(handler.body[-1])
|
|
650
|
+
):
|
|
651
|
+
return False
|
|
652
|
+
if stmt.orelse and not TypeChecker._is_terminal_statement(stmt.orelse[-1]):
|
|
653
|
+
return False
|
|
654
|
+
return True
|
|
655
|
+
if isinstance(stmt, ast.If):
|
|
656
|
+
if not (
|
|
657
|
+
bool(stmt.body) and TypeChecker._is_terminal_statement(stmt.body[-1])
|
|
658
|
+
):
|
|
659
|
+
return False
|
|
660
|
+
if not (
|
|
661
|
+
bool(stmt.orelse)
|
|
662
|
+
and TypeChecker._is_terminal_statement(stmt.orelse[-1])
|
|
663
|
+
):
|
|
664
|
+
return False
|
|
665
|
+
return True
|
|
666
|
+
if isinstance(stmt, ast.While):
|
|
667
|
+
if isinstance(stmt.test, ast.Constant) and bool(stmt.test.value):
|
|
668
|
+
has_return = any(
|
|
669
|
+
isinstance(n, (ast.Return, ast.Raise)) for n in ast.walk(stmt)
|
|
670
|
+
)
|
|
671
|
+
has_break = any(isinstance(n, ast.Break) for n in ast.walk(stmt))
|
|
672
|
+
if has_return and not has_break:
|
|
673
|
+
return True
|
|
674
|
+
if isinstance(stmt, getattr(ast, "Match", ())):
|
|
675
|
+
cases = getattr(stmt, "cases", [])
|
|
676
|
+
if not cases:
|
|
677
|
+
return False
|
|
678
|
+
return all(
|
|
679
|
+
bool(c.body) and TypeChecker._is_terminal_statement(c.body[-1])
|
|
680
|
+
for c in cases
|
|
681
|
+
)
|
|
682
|
+
return False
|
|
683
|
+
|
|
596
684
|
def _check_return_paths(
|
|
597
685
|
self,
|
|
598
686
|
func: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
@@ -649,7 +737,7 @@ class TypeChecker:
|
|
|
649
737
|
)
|
|
650
738
|
):
|
|
651
739
|
last_stmt = func.body[-1] if func.body else None
|
|
652
|
-
ends_with_terminal =
|
|
740
|
+
ends_with_terminal = self._is_terminal_statement(last_stmt)
|
|
653
741
|
if not ends_with_terminal:
|
|
654
742
|
snippet = (
|
|
655
743
|
ctx.source_lines[func.lineno - 1]
|
|
@@ -1009,18 +1097,21 @@ class TypeChecker:
|
|
|
1009
1097
|
return parse_type_annotation(cls_sig.methods[func.attr].return_type)
|
|
1010
1098
|
return None
|
|
1011
1099
|
|
|
1012
|
-
def _infer_typeshed_lookup(
|
|
1100
|
+
def _infer_typeshed_lookup(
|
|
1101
|
+
self, target: str, func_name: str, is_attribute: bool = False
|
|
1102
|
+
) -> PyType:
|
|
1013
1103
|
if target:
|
|
1014
1104
|
sig = self.typeshed.resolve_function(target)
|
|
1015
1105
|
if sig and sig.return_type and sig.return_type != "Any":
|
|
1016
1106
|
return parse_type_annotation(sig.return_type)
|
|
1017
1107
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1108
|
+
if not is_attribute:
|
|
1109
|
+
builtin_ret = self.typeshed.get_builtin_return_type(func_name)
|
|
1110
|
+
if builtin_ret:
|
|
1111
|
+
return parse_type_annotation(builtin_ret)
|
|
1021
1112
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1113
|
+
if func_name and self.typeshed.resolve_class("builtins", func_name):
|
|
1114
|
+
return CustomClassType(func_name)
|
|
1024
1115
|
|
|
1025
1116
|
return AnyType()
|
|
1026
1117
|
|
|
@@ -1031,21 +1122,33 @@ class TypeChecker:
|
|
|
1031
1122
|
local_functions: dict[str, tuple[dict[str, PyType], PyType]],
|
|
1032
1123
|
) -> PyType:
|
|
1033
1124
|
func_name, dotted_name = self._resolve_call_names(expr)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1125
|
+
if not isinstance(expr.func, ast.Attribute):
|
|
1126
|
+
ctor = self._infer_constructor_call(
|
|
1127
|
+
func_name, expr.args, scope, local_functions
|
|
1128
|
+
)
|
|
1129
|
+
if ctor is not None:
|
|
1130
|
+
return ctor
|
|
1039
1131
|
|
|
1040
1132
|
if isinstance(expr.func, ast.Attribute):
|
|
1041
1133
|
meth = self._infer_attribute_method(expr.func, scope, local_functions)
|
|
1042
1134
|
if meth is not None:
|
|
1043
1135
|
return meth
|
|
1136
|
+
if isinstance(expr.func.value, ast.Name) and expr.func.value.id in (
|
|
1137
|
+
"self",
|
|
1138
|
+
"cls",
|
|
1139
|
+
):
|
|
1140
|
+
if func_name in local_functions:
|
|
1141
|
+
return local_functions[func_name][1]
|
|
1142
|
+
return self._infer_typeshed_lookup(
|
|
1143
|
+
dotted_name, func_name, is_attribute=True
|
|
1144
|
+
)
|
|
1044
1145
|
|
|
1045
1146
|
if func_name in local_functions:
|
|
1046
1147
|
return local_functions[func_name][1]
|
|
1047
1148
|
|
|
1048
|
-
return self._infer_typeshed_lookup(
|
|
1149
|
+
return self._infer_typeshed_lookup(
|
|
1150
|
+
dotted_name or func_name, func_name, is_attribute=False
|
|
1151
|
+
)
|
|
1049
1152
|
|
|
1050
1153
|
def _infer_expr_type(
|
|
1051
1154
|
self,
|
pycleaner/verifier.py
CHANGED
|
@@ -99,8 +99,12 @@ def _worker_differential_fuzz(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
try:
|
|
102
|
-
orig_code = compile(
|
|
103
|
-
|
|
102
|
+
orig_code = compile(
|
|
103
|
+
orig_source, "<orig_sandbox>", "exec"
|
|
104
|
+
) # nosec: B102 - intentionally isolated in worker process
|
|
105
|
+
exec(
|
|
106
|
+
orig_code, orig_ns
|
|
107
|
+
) # nosec: B102 - intentionally isolated in worker process
|
|
104
108
|
except BaseException as e:
|
|
105
109
|
conn.send(
|
|
106
110
|
{
|
|
@@ -111,8 +115,12 @@ def _worker_differential_fuzz(
|
|
|
111
115
|
return
|
|
112
116
|
|
|
113
117
|
try:
|
|
114
|
-
trans_code = compile(
|
|
115
|
-
|
|
118
|
+
trans_code = compile(
|
|
119
|
+
trans_source, "<trans_sandbox>", "exec"
|
|
120
|
+
) # nosec: B102 - intentionally isolated in worker process
|
|
121
|
+
exec(
|
|
122
|
+
trans_code, trans_ns
|
|
123
|
+
) # nosec: B102 - intentionally isolated in worker process
|
|
116
124
|
except BaseException as e:
|
|
117
125
|
conn.send(
|
|
118
126
|
{
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
ballpython/__init__.py,sha256=GSB-LHdou1Un6DS6Zmh8tv_zrwh6HQHO3Py2z8GO39o,304
|
|
2
|
-
ballpython/__main__.py,sha256=aCRr0vnZfKYHDrPHBsYSjX5xTxtFJpTTSPVUSZTBK-I,136
|
|
3
|
-
ballpython/cli.py,sha256=LllCLRAQQ-fRR_lIXwmd2e3Q7DLCIvf-5jHBXsAhJWs,125
|
|
4
|
-
ballpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
pycleaner/__init__.py,sha256=VzmbjUip6i0wpKh-s6XBwgBkIeUgI1duGyKDIiv7Pls,3042
|
|
6
|
-
pycleaner/__main__.py,sha256=8QBFrPyxId563F6cipPMNVEBTG8TIZUgxQfp8gk1VSI,135
|
|
7
|
-
pycleaner/baseline.py,sha256=ljED3x3akie0SB3fzhVno_1UHlI2id-9vta0NIGC0mw,3857
|
|
8
|
-
pycleaner/cache.py,sha256=T3JIxYg8ZezRbSpXYXPD-SyMUkjg4nfKsdO0IuNypOY,10774
|
|
9
|
-
pycleaner/cli.py,sha256=NswwV_3cbHiL63Pj6qDtSpV_98zXvGo7UuBHwNAxpQ8,73752
|
|
10
|
-
pycleaner/complexity_analyzer.py,sha256=j2ktzgR-isWE4xWMe56N3JFLLuYCi-S84BIlRBe0v-I,14975
|
|
11
|
-
pycleaner/config.py,sha256=XhloyaY_9pdwbQuc3QaAPCerLeXz-i1da4xT50h-pjY,8514
|
|
12
|
-
pycleaner/dead_code_detector.py,sha256=uufGt7EtGusQdtbX3rUCHIrF8IAu-S16jrViBJdnV-g,52092
|
|
13
|
-
pycleaner/dependency_auditor.py,sha256=EmSqr_ayUqmdSZ7Nm4QAT4qfikF9VToLL8eXVecIDpw,23395
|
|
14
|
-
pycleaner/discovery.py,sha256=dt3OmHg886hy_Z0p80ccMyDkITQxz4biaJY_zB3MU_g,6151
|
|
15
|
-
pycleaner/explanations.py,sha256=2OOU_l3YL5o_Br_6fP6MF8HlY75-1JRmw3eTYT2lu8M,14219
|
|
16
|
-
pycleaner/import_resolver.py,sha256=Wgrch335AQVsnDv5-7F0l4Zm2oQPkNebPEgTRa7ayAg,32855
|
|
17
|
-
pycleaner/linter_formatter.py,sha256=wdTjwguPWzkJ9KRMcQpFHEirb3ogc9qay69yiphz16Y,21356
|
|
18
|
-
pycleaner/modernizer.py,sha256=kd9_G5EzdW_O-9zm1hA6v33Sb__UbpMZzSvopF-UK5w,16820
|
|
19
|
-
pycleaner/pipeline.py,sha256=ZG7_9PNz0jBadFrZoiI7juLtUJXglGQhLgQhrLJqEac,20612
|
|
20
|
-
pycleaner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
-
pycleaner/security_scanner.py,sha256=8nyRtoCjBYAB_sI5azlNsEMm2S0s2zE7fPhc7QCHWR0,23527
|
|
22
|
-
pycleaner/syntax_healer.py,sha256=5VYclqkI3WFnm6yfG0zRLMkHN4QMU4aN4ZYCcSUsmZk,27408
|
|
23
|
-
pycleaner/taint_engine.py,sha256=-bBq38YoQgbO2FHCDxDctZpXWQTqwuS56CEWFNOY000,26870
|
|
24
|
-
pycleaner/test_generator.py,sha256=MM3t0Dx5lyJB5s144IKXq2tXrE8ohKcSvicPR3c9MlU,19680
|
|
25
|
-
pycleaner/type_checker.py,sha256=zkUqi-BNLUzJT0tIECNh7_7GP29Y7_6fMXhVuguwMKg,37881
|
|
26
|
-
pycleaner/typeshed_resolver.py,sha256=N-kWU_CI5DG59lQMkt7wMW9sHYjNCBdfFS42b4y2Kr4,12742
|
|
27
|
-
pycleaner/verifier.py,sha256=QlBwnsrhWWRK3LzvJjpNix474drCNp6LP7FiUgJxMsg,19333
|
|
28
|
-
pycleaner/frameworks/__init__.py,sha256=SOuSRl39af86eoQx7Qm8rTJGD4jsnEvgyhhQ6cVmRVA,3462
|
|
29
|
-
pycleaner/frameworks/plugins.py,sha256=qunMzS3q7xp1GEGxqkuhrnm-raiSRhYQMzmDxsEhSUg,11452
|
|
30
|
-
ballpython-2.1.0.dist-info/METADATA,sha256=98OjKH0i9ih05fQ9JX2eriRljEboAEOVIgk4TwQ9hQ8,3466
|
|
31
|
-
ballpython-2.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
-
ballpython-2.1.0.dist-info/entry_points.txt,sha256=6ux-5UxoVSmlK5p9M-GDVt24sCdwTQq9wgRDkpMIYU8,81
|
|
33
|
-
ballpython-2.1.0.dist-info/top_level.txt,sha256=u6lmy8RWZ_saxCZAQTL7EijB4sunegOiVyk5fHjpwLk,21
|
|
34
|
-
ballpython-2.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|