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.
@@ -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\.\(\)\s]+),\s*([a-zA-Z_]\w*)\s*:(.*)$",
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=source,
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
- return code, 0
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(self, target: str | Path) -> TaintReport:
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 = sorted(
269
- f
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()
@@ -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(module_name, str(path), test_cases)
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="assert result is not None or result is None # Ensures function executes cleanly",
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="assert True # Confirms boundary condition completes without unexpected crash",
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
- assertion_stmt="assert result is not None or result is None",
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
- lines.extend(
404
- [
405
- " try:",
406
- f" result = {call_prefix}{func_invocation}",
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" {tc.assertion_stmt}")
411
- lines.extend(
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 Ultimate."""',
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(self, root: Path) -> list[Path]:
502
- files: list[Path] = []
503
- walker = (
504
- Path(root).walk() if hasattr(Path, "walk") else self._fallback_walk(root)
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(self, root_dir: str | Path) -> TypeReport:
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, {}