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.
@@ -0,0 +1,989 @@
1
+ """
2
+ Static bidirectional type inference and type checking engine.
3
+
4
+ Inspects AST expressions, evaluates algebraic types, validates return types
5
+ against declared signatures, checks call argument type compatibility,
6
+ and performs type narrowing across conditional branches.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from pycleaner.typeshed_resolver import TypeshedResolver
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Algebraic Type System
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class PyType:
23
+ """Base algebraic type representation."""
24
+
25
+ def is_assignable_to(self, target: PyType) -> bool:
26
+ """Check if this type can be assigned to target type."""
27
+ if isinstance(target, AnyType) or isinstance(self, AnyType) or target == self:
28
+ return True
29
+ if isinstance(target, UnionType):
30
+ return self._assignable_to_union(target)
31
+ if isinstance(self, UnionType):
32
+ return self._union_assignable_to(target)
33
+ if isinstance(target, CustomClassType) and self._check_custom_target(target):
34
+ return True
35
+ if isinstance(self, CustomClassType) and self._check_custom_source(target):
36
+ return True
37
+ return self._check_numeric_promotions(target)
38
+
39
+ def _assignable_to_union(self, target: PyType) -> bool:
40
+ types = getattr(target, "types", [])
41
+ for u in types:
42
+ if self.is_assignable_to(u):
43
+ return True
44
+ return False
45
+
46
+ def _union_assignable_to(self, target: PyType) -> bool:
47
+ types = getattr(self, "types", [])
48
+ non_none = [u for u in types if not isinstance(u, NoneType)]
49
+ if non_none:
50
+ all_match = True
51
+ for u in non_none:
52
+ if not u.is_assignable_to(target):
53
+ all_match = False
54
+ break
55
+ if all_match:
56
+ return True
57
+ for u in types:
58
+ if not u.is_assignable_to(target):
59
+ return False
60
+ return True
61
+
62
+ def _check_custom_target(self, target: PyType) -> bool:
63
+ if not isinstance(target, CustomClassType):
64
+ return False
65
+ if target.name in ("StrPath", "PathLike", "AnyStr"):
66
+ if isinstance(self, PrimitiveType) and self.name in ("str", "bytes"):
67
+ return True
68
+ if isinstance(self, CustomClassType) and self.name in (
69
+ "Path",
70
+ "PosixPath",
71
+ "WindowsPath",
72
+ "str",
73
+ "bytes",
74
+ ):
75
+ return True
76
+ return (
77
+ target.name.startswith("Supports")
78
+ or target.name.startswith("_")
79
+ or target.name in ("T", "Any")
80
+ )
81
+
82
+ def _check_custom_source(self, target: PyType) -> bool:
83
+ if self.name in ("StrPath", "PathLike", "AnyStr"): # type: ignore[attr-defined]
84
+ if isinstance(target, PrimitiveType) and target.name in ("str", "bytes"):
85
+ return True
86
+ if isinstance(target, CustomClassType) and target.name in (
87
+ "Path",
88
+ "PosixPath",
89
+ "WindowsPath",
90
+ "str",
91
+ "bytes",
92
+ ):
93
+ return True
94
+ return False
95
+
96
+ def _check_numeric_promotions(self, target: PyType) -> bool:
97
+ if isinstance(self, PrimitiveType) and isinstance(target, PrimitiveType):
98
+ if self.name == "int" and target.name == "float":
99
+ return True
100
+ if self.name == "bool" and target.name in ("int", "float"):
101
+ return True
102
+ return False
103
+
104
+ def __str__(self) -> str:
105
+ return self.__class__.__name__
106
+
107
+
108
+ class AnyType(PyType):
109
+ def __eq__(self, other: object) -> bool:
110
+ return isinstance(other, AnyType)
111
+
112
+ def __str__(self) -> str:
113
+ return "Any"
114
+
115
+
116
+ class NoneType(PyType):
117
+ def __eq__(self, other: object) -> bool:
118
+ return isinstance(other, NoneType)
119
+
120
+ def __str__(self) -> str:
121
+ return "None"
122
+
123
+
124
+ class PrimitiveType(PyType):
125
+ def __init__(self, name: str) -> None:
126
+ self.name = name
127
+
128
+ def __eq__(self, other: object) -> bool:
129
+ return isinstance(other, PrimitiveType) and self.name == other.name
130
+
131
+ def __str__(self) -> str:
132
+ return self.name
133
+
134
+
135
+ class UnionType(PyType):
136
+ types: list[PyType]
137
+
138
+ def __init__(self, types: list[PyType]) -> None:
139
+ flat: list[PyType] = []
140
+ for t in types:
141
+ if isinstance(t, UnionType):
142
+ flat.extend(t.types)
143
+ elif t not in flat:
144
+ flat.append(t)
145
+ self.types = flat
146
+
147
+ def __eq__(self, other: object) -> bool:
148
+ return isinstance(other, UnionType) and {str(t) for t in self.types} == {
149
+ str(t) for t in other.types
150
+ }
151
+
152
+ def __str__(self) -> str:
153
+ return " | ".join(str(t) for t in self.types)
154
+
155
+
156
+ def _is_collection_assignable(source_item: PyType, target: PyType) -> bool:
157
+ if isinstance(target, AnyType):
158
+ return True
159
+ if isinstance(target, (ListType, SetType)):
160
+ if isinstance(target.item_type, AnyType) or isinstance(source_item, AnyType):
161
+ return True
162
+ if isinstance(target.item_type, CustomClassType) and (
163
+ target.item_type.name.startswith("Supports")
164
+ or target.item_type.name.startswith("_")
165
+ or target.item_type.name in ("T", "Any")
166
+ ):
167
+ return True
168
+ return source_item.is_assignable_to(target.item_type)
169
+ return isinstance(target, CustomClassType) and target.name in (
170
+ "list",
171
+ "List",
172
+ "set",
173
+ "Set",
174
+ "Sequence",
175
+ "Iterable",
176
+ "Collection",
177
+ )
178
+
179
+
180
+ class ListType(PyType):
181
+ def __init__(self, item_type: PyType) -> None:
182
+ self.item_type = item_type
183
+
184
+ def is_assignable_to(self, target: PyType) -> bool:
185
+ if _is_collection_assignable(self.item_type, target):
186
+ return True
187
+ return super().is_assignable_to(target)
188
+
189
+ def __eq__(self, other: object) -> bool:
190
+ return isinstance(other, ListType) and self.item_type == other.item_type
191
+
192
+ def __str__(self) -> str:
193
+ return f"list[{self.item_type}]"
194
+
195
+
196
+ class SetType(PyType):
197
+ def __init__(self, item_type: PyType) -> None:
198
+ self.item_type = item_type
199
+
200
+ def is_assignable_to(self, target: PyType) -> bool:
201
+ if _is_collection_assignable(self.item_type, target):
202
+ return True
203
+ return super().is_assignable_to(target)
204
+
205
+ def __eq__(self, other: object) -> bool:
206
+ return isinstance(other, SetType) and self.item_type == other.item_type
207
+
208
+ def __str__(self) -> str:
209
+ return f"set[{self.item_type}]"
210
+
211
+
212
+ class DictType(PyType):
213
+ def __init__(self, key_type: PyType, value_type: PyType) -> None:
214
+ self.key_type = key_type
215
+ self.value_type = value_type
216
+
217
+ def is_assignable_to(self, target: PyType) -> bool:
218
+ if isinstance(target, AnyType):
219
+ return True
220
+ if isinstance(target, DictType):
221
+ return self.key_type.is_assignable_to(
222
+ target.key_type
223
+ ) and self.value_type.is_assignable_to(target.value_type)
224
+ if isinstance(target, CustomClassType) and target.name in (
225
+ "dict",
226
+ "Dict",
227
+ "Mapping",
228
+ ):
229
+ return True
230
+ return super().is_assignable_to(target)
231
+
232
+ def __eq__(self, other: object) -> bool:
233
+ return (
234
+ isinstance(other, DictType)
235
+ and self.key_type == other.key_type
236
+ and self.value_type == other.value_type
237
+ )
238
+
239
+ def __str__(self) -> str:
240
+ return f"dict[{self.key_type}, {self.value_type}]"
241
+
242
+
243
+ class CustomClassType(PyType):
244
+ def __init__(self, name: str) -> None:
245
+ self.name = name
246
+
247
+ def __eq__(self, other: object) -> bool:
248
+ return isinstance(other, CustomClassType) and self.name == other.name
249
+
250
+ def __str__(self) -> str:
251
+ return self.name
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Diagnostics & Reports
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ @dataclass(slots=True)
260
+ class TypeFinding:
261
+ """A detected type mismatch or type violation."""
262
+
263
+ filepath: str
264
+ lineno: int
265
+ column: int
266
+ symbol: str
267
+ expected_type: str
268
+ actual_type: str
269
+ message: str
270
+ severity: str = "ERROR"
271
+ category: str = "type-mismatch"
272
+ code_snippet: str = ""
273
+
274
+
275
+ @dataclass(slots=True)
276
+ class TypeReport:
277
+ """Summary of static type checking results."""
278
+
279
+ findings: list[TypeFinding] = field(default_factory=list)
280
+ files_scanned: int = 0
281
+ functions_checked: int = 0
282
+
283
+ @property
284
+ def count(self) -> int:
285
+ return len(self.findings)
286
+
287
+ @property
288
+ def has_errors(self) -> bool:
289
+ return any(f.severity == "ERROR" for f in self.findings)
290
+
291
+ @property
292
+ def error_count(self) -> int:
293
+ return sum(1 for f in self.findings if f.severity == "ERROR")
294
+
295
+ @property
296
+ def warning_count(self) -> int:
297
+ return sum(1 for f in self.findings if f.severity == "WARNING")
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Type Parser
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def _parse_name_annotation(name: str) -> PyType:
306
+ """Resolve identifier names to primitive, collection, or custom PyTypes."""
307
+ if name in ("int", "str", "float", "bool", "bytes"):
308
+ return PrimitiveType(name)
309
+ if name in ("None", "NoneType"):
310
+ return NoneType()
311
+ if name == "Any":
312
+ return AnyType()
313
+ if name in ("set", "Set"):
314
+ return SetType(AnyType())
315
+ if name in ("list", "List", "Sequence", "Iterable", "Collection", "tuple", "Tuple"):
316
+ return ListType(AnyType())
317
+ if name in ("dict", "Dict", "Mapping"):
318
+ return DictType(AnyType(), AnyType())
319
+ return CustomClassType(name)
320
+
321
+
322
+ def _parse_dict_subscript(slice_node: ast.expr) -> PyType:
323
+ if isinstance(slice_node, ast.Tuple) and len(slice_node.elts) == 2:
324
+ return DictType(
325
+ parse_type_annotation(slice_node.elts[0]),
326
+ parse_type_annotation(slice_node.elts[1]),
327
+ )
328
+ return DictType(AnyType(), AnyType())
329
+
330
+
331
+ def _parse_union_subscript(slice_node: ast.expr) -> PyType:
332
+ if isinstance(slice_node, ast.Tuple):
333
+ return UnionType([parse_type_annotation(e) for e in slice_node.elts])
334
+ return parse_type_annotation(slice_node)
335
+
336
+
337
+ def _parse_subscript_annotation(node: ast.Subscript) -> PyType:
338
+ """Resolve subscripted type annotations (generics, optionals, unions)."""
339
+ val = node.value
340
+ base_name = (
341
+ val.id
342
+ if isinstance(val, ast.Name)
343
+ else (val.attr if isinstance(val, ast.Attribute) else "")
344
+ )
345
+
346
+ if base_name == "Optional":
347
+ return UnionType([parse_type_annotation(node.slice), NoneType()])
348
+ if base_name == "Union":
349
+ res = _parse_union_subscript(node.slice)
350
+ return res if isinstance(res, UnionType) else UnionType([res])
351
+ if base_name in ("set", "Set"):
352
+ return SetType(parse_type_annotation(node.slice))
353
+ if base_name in (
354
+ "list",
355
+ "List",
356
+ "Sequence",
357
+ "Iterable",
358
+ "Collection",
359
+ "tuple",
360
+ "Tuple",
361
+ ):
362
+ return ListType(parse_type_annotation(node.slice))
363
+ if base_name in ("dict", "Dict", "Mapping"):
364
+ return _parse_dict_subscript(node.slice)
365
+ return AnyType()
366
+
367
+
368
+ def _parse_constant_annotation(annotation: ast.Constant) -> PyType:
369
+ if annotation.value is None:
370
+ return NoneType()
371
+ if isinstance(annotation.value, str):
372
+ return parse_type_annotation(annotation.value)
373
+ return AnyType()
374
+
375
+
376
+ def parse_type_annotation(annotation: ast.AST | str | None) -> PyType:
377
+ """Parse an AST node or type string into a PyType representation."""
378
+ if annotation is None:
379
+ return AnyType()
380
+
381
+ if isinstance(annotation, str):
382
+ try:
383
+ return parse_type_annotation(ast.parse(annotation, mode="eval").body)
384
+ except SyntaxError:
385
+ return AnyType()
386
+
387
+ if isinstance(annotation, ast.Name):
388
+ return _parse_name_annotation(annotation.id)
389
+
390
+ if isinstance(annotation, ast.Constant):
391
+ return _parse_constant_annotation(annotation)
392
+
393
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
394
+ return UnionType(
395
+ [
396
+ parse_type_annotation(annotation.left),
397
+ parse_type_annotation(annotation.right),
398
+ ]
399
+ )
400
+
401
+ if isinstance(annotation, ast.Subscript):
402
+ return _parse_subscript_annotation(annotation)
403
+
404
+ return AnyType()
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Type Checker Engine
409
+ # ---------------------------------------------------------------------------
410
+
411
+
412
+ @dataclass(slots=True)
413
+ class _FileContext:
414
+ filepath: str
415
+ source_lines: list[str]
416
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]] = field(
417
+ default_factory=dict
418
+ )
419
+
420
+
421
+ class TypeChecker:
422
+ """Type checker verifying annotations, return statements, and call sites."""
423
+
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
+ def __init__(
442
+ self, typeshed: TypeshedResolver | None = None, strict: bool = False
443
+ ) -> None:
444
+ self.typeshed = typeshed or TypeshedResolver()
445
+ self.strict = strict
446
+
447
+ def check_file(self, filepath: str | Path) -> list[TypeFinding]:
448
+ """Check all functions and statements in a file."""
449
+ path = Path(filepath)
450
+ try:
451
+ content = path.read_text(encoding="utf-8", errors="replace")
452
+ tree = ast.parse(content, filename=str(path))
453
+ except SyntaxError:
454
+ return []
455
+
456
+ return self.check_ast(tree, filepath=str(path), source=content)
457
+
458
+ @staticmethod
459
+ def _collect_local_signatures(
460
+ tree: ast.Module,
461
+ ) -> dict[str, tuple[dict[str, PyType], PyType]]:
462
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]] = {}
463
+ for node in ast.walk(tree):
464
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
465
+ params: dict[str, PyType] = {}
466
+ all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
467
+ for a in all_args:
468
+ params[a.arg] = (
469
+ parse_type_annotation(a.annotation)
470
+ if a.annotation
471
+ else AnyType()
472
+ )
473
+ ret_type = (
474
+ parse_type_annotation(node.returns) if node.returns else AnyType()
475
+ )
476
+ local_functions[node.name] = (params, ret_type)
477
+ return local_functions
478
+
479
+ def check_ast(
480
+ self,
481
+ tree: ast.Module,
482
+ filepath: str = "<unknown>",
483
+ source: str = "",
484
+ ) -> list[TypeFinding]:
485
+ """Perform static type verification on an AST module."""
486
+ findings: list[TypeFinding] = []
487
+ ctx = _FileContext(
488
+ filepath=filepath,
489
+ source_lines=source.splitlines() if source else [],
490
+ local_functions=self._collect_local_signatures(tree),
491
+ )
492
+
493
+ for node in ast.walk(tree):
494
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
495
+ findings.extend(self._check_function(node, ctx))
496
+ elif isinstance(node, ast.AnnAssign):
497
+ findings.extend(self._check_ann_assign(node, ctx))
498
+
499
+ return findings
500
+
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
514
+
515
+ @staticmethod
516
+ def _count_functions(fpath: Path) -> int:
517
+ try:
518
+ tree = ast.parse(fpath.read_text(encoding="utf-8", errors="replace"))
519
+ return sum(
520
+ 1
521
+ for n in ast.walk(tree)
522
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
523
+ )
524
+ except SyntaxError:
525
+ return 0
526
+
527
+ def check_project(self, root_dir: str | Path) -> TypeReport:
528
+ """Type-check all Python files across an entire project directory."""
529
+ root = Path(root_dir).resolve()
530
+ findings: list[TypeFinding] = []
531
+ functions_checked = 0
532
+ py_files = self._discover_python_files(root)
533
+
534
+ for fpath in py_files:
535
+ findings.extend(self.check_file(fpath))
536
+ functions_checked += self._count_functions(fpath)
537
+
538
+ return TypeReport(
539
+ findings=findings,
540
+ files_scanned=len(py_files),
541
+ functions_checked=functions_checked,
542
+ )
543
+
544
+ def _fallback_walk(self, root: Path):
545
+ import os
546
+
547
+ for r, d, f in os.walk(root):
548
+ yield Path(r), d, f
549
+
550
+ @staticmethod
551
+ def _init_param_scope(
552
+ func: ast.FunctionDef | ast.AsyncFunctionDef,
553
+ ) -> dict[str, PyType]:
554
+ scope: dict[str, PyType] = {}
555
+ all_args = func.args.posonlyargs + func.args.args + func.args.kwonlyargs
556
+ for a in all_args:
557
+ scope[a.arg] = (
558
+ parse_type_annotation(a.annotation) if a.annotation else AnyType()
559
+ )
560
+ return scope
561
+
562
+ def _check_return_paths(
563
+ self,
564
+ func: ast.FunctionDef | ast.AsyncFunctionDef,
565
+ declared_return: PyType,
566
+ scope: dict[str, PyType],
567
+ ctx: _FileContext,
568
+ ) -> list[TypeFinding]:
569
+ if isinstance(declared_return, AnyType):
570
+ return []
571
+
572
+ findings: list[TypeFinding] = []
573
+ for child in ast.walk(func):
574
+ if not isinstance(child, ast.Return):
575
+ continue
576
+ if self._is_nested_in_other_func(child, func):
577
+ continue
578
+
579
+ actual_type = self._infer_expr_type(child.value, scope, ctx.local_functions)
580
+ if not actual_type.is_assignable_to(declared_return):
581
+ snippet = (
582
+ ctx.source_lines[child.lineno - 1]
583
+ if 0 <= child.lineno - 1 < len(ctx.source_lines)
584
+ else ""
585
+ )
586
+ findings.append(
587
+ TypeFinding(
588
+ filepath=ctx.filepath,
589
+ lineno=child.lineno,
590
+ column=child.col_offset,
591
+ symbol=func.name,
592
+ expected_type=str(declared_return),
593
+ actual_type=str(actual_type),
594
+ message=(
595
+ f"Incompatible return type: function '{func.name}' declared to return "
596
+ f"'{declared_return}' but returned '{actual_type}'"
597
+ ),
598
+ severity="ERROR",
599
+ code_snippet=snippet.strip(),
600
+ )
601
+ )
602
+
603
+ return findings
604
+
605
+ def _check_function(
606
+ self,
607
+ func: ast.FunctionDef | ast.AsyncFunctionDef,
608
+ ctx: _FileContext,
609
+ ) -> list[TypeFinding]:
610
+ """Check return statement types and local assignments within a function."""
611
+ declared_return = (
612
+ parse_type_annotation(func.returns) if func.returns else AnyType()
613
+ )
614
+ scope = self._init_param_scope(func)
615
+ findings = self._check_return_paths(func, declared_return, scope, ctx)
616
+
617
+ for child in ast.walk(func):
618
+ if isinstance(child, ast.Call):
619
+ findings.extend(self._check_call_arguments(child, scope, ctx))
620
+
621
+ return findings
622
+
623
+ def _check_ann_assign(
624
+ self,
625
+ node: ast.AnnAssign,
626
+ ctx: _FileContext,
627
+ ) -> list[TypeFinding]:
628
+ """Check explicit variable annotation vs assigned value."""
629
+ if not node.value or not isinstance(node.target, ast.Name):
630
+ return []
631
+
632
+ declared_type = parse_type_annotation(node.annotation)
633
+ if isinstance(declared_type, AnyType):
634
+ return []
635
+
636
+ actual_type = self._infer_expr_type(node.value, {}, ctx.local_functions)
637
+ if isinstance(actual_type, AnyType) or actual_type.is_assignable_to(
638
+ declared_type
639
+ ):
640
+ return []
641
+
642
+ snippet = (
643
+ ctx.source_lines[node.lineno - 1]
644
+ if 0 <= node.lineno - 1 < len(ctx.source_lines)
645
+ else ""
646
+ )
647
+ return [
648
+ TypeFinding(
649
+ filepath=ctx.filepath,
650
+ lineno=node.lineno,
651
+ column=node.col_offset,
652
+ symbol=node.target.id,
653
+ expected_type=str(declared_type),
654
+ actual_type=str(actual_type),
655
+ message=(
656
+ f"Variable '{node.target.id}' declared as '{declared_type}' "
657
+ f"assigned incompatible type '{actual_type}'"
658
+ ),
659
+ severity="ERROR",
660
+ code_snippet=snippet.strip(),
661
+ )
662
+ ]
663
+
664
+ @staticmethod
665
+ def _resolve_call_names(call: ast.Call) -> tuple[str, str]:
666
+ """Extract the short function name and dotted name from a Call node."""
667
+ func = call.func
668
+ if isinstance(func, ast.Name):
669
+ return func.id, ""
670
+ if isinstance(func, ast.Attribute):
671
+ func_name = func.attr
672
+ dotted_parts: list[str] = []
673
+ curr: ast.expr = func
674
+ while isinstance(curr, ast.Attribute):
675
+ dotted_parts.append(curr.attr)
676
+ curr = curr.value
677
+ if isinstance(curr, ast.Name):
678
+ dotted_parts.append(curr.id)
679
+ return func_name, ".".join(reversed(dotted_parts))
680
+ return func_name, ""
681
+ return "", ""
682
+
683
+ @staticmethod
684
+ def _clean_receiver_params(
685
+ param_types: dict[str, PyType], call: ast.Call
686
+ ) -> dict[str, PyType]:
687
+ if isinstance(call.func, ast.Attribute) and param_types:
688
+ first_param = next(iter(param_types))
689
+ if first_param in ("self", "cls"):
690
+ param_types.pop(first_param, None)
691
+ return param_types
692
+
693
+ def _resolve_call_param_types(
694
+ self,
695
+ call: ast.Call,
696
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
697
+ ) -> tuple[str, dict[str, PyType]]:
698
+ """Resolve function name and parameter types for a Call node."""
699
+ func_name, dotted_name = self._resolve_call_names(call)
700
+ if func_name in local_functions:
701
+ cleaned = self._clean_receiver_params(
702
+ dict(local_functions[func_name][0]), call
703
+ )
704
+ return func_name, cleaned
705
+
706
+ target_lookup = dotted_name or func_name
707
+ if not target_lookup:
708
+ return func_name, {}
709
+
710
+ sig = self.typeshed.resolve_function(target_lookup)
711
+ if not sig or not sig.param_types:
712
+ return func_name, {}
713
+
714
+ param_types = {k: parse_type_annotation(v) for k, v in sig.param_types.items()}
715
+ if (sig.is_method and not sig.is_static) or sig.is_class_method:
716
+ param_types.pop(next(iter(param_types), ""), None)
717
+ return func_name, param_types
718
+
719
+ return func_name, self._clean_receiver_params(param_types, call)
720
+
721
+ @staticmethod
722
+ def _make_call_arg_finding(
723
+ ctx: _FileContext,
724
+ call: ast.Call,
725
+ func_param: tuple[str, str],
726
+ exp_act: tuple[PyType, PyType],
727
+ ) -> TypeFinding:
728
+ func_name, param_name = func_param
729
+ expected, actual = exp_act
730
+ snippet = (
731
+ ctx.source_lines[call.lineno - 1].strip()
732
+ if 0 <= call.lineno - 1 < len(ctx.source_lines)
733
+ else ""
734
+ )
735
+ return TypeFinding(
736
+ filepath=ctx.filepath,
737
+ lineno=call.lineno,
738
+ column=call.col_offset,
739
+ symbol=func_name,
740
+ expected_type=str(expected),
741
+ actual_type=str(actual),
742
+ message=(
743
+ f"Argument '{param_name}' to '{func_name}' expects '{expected}' "
744
+ f"but received '{actual}'"
745
+ ),
746
+ severity="ERROR",
747
+ code_snippet=snippet,
748
+ )
749
+
750
+ def _check_call_arguments(
751
+ self,
752
+ call: ast.Call,
753
+ scope: dict[str, PyType],
754
+ ctx: _FileContext,
755
+ ) -> list[TypeFinding]:
756
+ """Validate argument types against function parameter annotations."""
757
+ func_name, param_types = self._resolve_call_param_types(
758
+ call, ctx.local_functions
759
+ )
760
+ if not param_types:
761
+ return []
762
+
763
+ param_names = list(param_types.keys())
764
+ findings: list[TypeFinding] = []
765
+
766
+ for idx, arg_expr in enumerate(call.args):
767
+ if idx >= len(param_names):
768
+ break
769
+ param_name = param_names[idx]
770
+ expected = param_types[param_name]
771
+ if isinstance(expected, AnyType):
772
+ continue
773
+
774
+ actual = self._infer_expr_type(arg_expr, scope, ctx.local_functions)
775
+ if isinstance(actual, AnyType) or actual.is_assignable_to(expected):
776
+ continue
777
+
778
+ findings.append(
779
+ self._make_call_arg_finding(
780
+ ctx, call, (func_name, param_name), (expected, actual)
781
+ )
782
+ )
783
+
784
+ return findings
785
+
786
+ def _infer_constant(self, expr: ast.Constant) -> PyType:
787
+ if expr.value is None:
788
+ return NoneType()
789
+ if isinstance(expr.value, bool):
790
+ return PrimitiveType("bool")
791
+ if isinstance(expr.value, int):
792
+ return PrimitiveType("int")
793
+ if isinstance(expr.value, float):
794
+ return PrimitiveType("float")
795
+ if isinstance(expr.value, str):
796
+ return PrimitiveType("str")
797
+ if isinstance(expr.value, bytes):
798
+ return PrimitiveType("bytes")
799
+ return AnyType()
800
+
801
+ def _infer_collection(
802
+ self,
803
+ expr: ast.List | ast.Set | ast.Dict,
804
+ scope: dict[str, PyType],
805
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
806
+ ) -> PyType:
807
+ if isinstance(expr, ast.List):
808
+ if not expr.elts:
809
+ return ListType(AnyType())
810
+ return ListType(self._infer_expr_type(expr.elts[0], scope, local_functions))
811
+ if isinstance(expr, ast.Set):
812
+ if not expr.elts:
813
+ return SetType(AnyType())
814
+ return SetType(self._infer_expr_type(expr.elts[0], scope, local_functions))
815
+ if not expr.keys or not expr.values:
816
+ return DictType(AnyType(), AnyType())
817
+ k_type = (
818
+ self._infer_expr_type(expr.keys[0], scope, local_functions)
819
+ if expr.keys[0]
820
+ else AnyType()
821
+ )
822
+ v_type = self._infer_expr_type(expr.values[0], scope, local_functions)
823
+ return DictType(k_type, v_type)
824
+
825
+ @staticmethod
826
+ def _infer_numeric_binop(op: ast.operator, left: str, right: str) -> PyType:
827
+ if isinstance(op, ast.Div):
828
+ return PrimitiveType("float")
829
+ if "float" in (left, right):
830
+ return PrimitiveType("float")
831
+ return PrimitiveType("int")
832
+
833
+ def _infer_binop(
834
+ self,
835
+ expr: ast.BinOp,
836
+ scope: dict[str, PyType],
837
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
838
+ ) -> PyType:
839
+ left_type = self._infer_expr_type(expr.left, scope, local_functions)
840
+ right_type = self._infer_expr_type(expr.right, scope, local_functions)
841
+ if isinstance(left_type, PrimitiveType) and isinstance(
842
+ right_type, PrimitiveType
843
+ ):
844
+ if (
845
+ left_type.name == "str"
846
+ and right_type.name == "str"
847
+ and isinstance(expr.op, ast.Add)
848
+ ):
849
+ return PrimitiveType("str")
850
+ if left_type.name in ("int", "float") and right_type.name in (
851
+ "int",
852
+ "float",
853
+ ):
854
+ return self._infer_numeric_binop(
855
+ expr.op, left_type.name, right_type.name
856
+ )
857
+ return AnyType()
858
+
859
+ def _extract_iterable_item_type(
860
+ self,
861
+ arg: ast.expr,
862
+ scope: dict[str, PyType],
863
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
864
+ ) -> PyType:
865
+ arg_t = self._infer_expr_type(arg, scope, local_functions)
866
+ if isinstance(arg_t, (ListType, SetType)):
867
+ return getattr(arg_t, "item_type", AnyType())
868
+ if isinstance(arg_t, PrimitiveType) and arg_t.name == "str":
869
+ return PrimitiveType("str")
870
+ return AnyType()
871
+
872
+ def _infer_constructor_call(
873
+ self,
874
+ func_name: str,
875
+ args: list[ast.expr],
876
+ scope: dict[str, PyType],
877
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
878
+ ) -> PyType | None:
879
+ if func_name in ("set", "Set"):
880
+ item_t = (
881
+ self._extract_iterable_item_type(args[0], scope, local_functions)
882
+ if args
883
+ else AnyType()
884
+ )
885
+ return SetType(item_t)
886
+
887
+ if func_name in ("list", "List", "sorted"):
888
+ item_t = (
889
+ self._extract_iterable_item_type(args[0], scope, local_functions)
890
+ if args
891
+ else AnyType()
892
+ )
893
+ return ListType(item_t)
894
+
895
+ if func_name == "sum":
896
+ return PrimitiveType("int")
897
+
898
+ return None
899
+
900
+ def _infer_attribute_method(
901
+ self,
902
+ func: ast.Attribute,
903
+ scope: dict[str, PyType],
904
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
905
+ ) -> PyType | None:
906
+ obj_type = self._infer_expr_type(func.value, scope, local_functions)
907
+ if isinstance(obj_type, PrimitiveType):
908
+ m_ret = self.typeshed.get_builtin_method_return_type(
909
+ obj_type.name, func.attr
910
+ )
911
+ if m_ret:
912
+ return parse_type_annotation(m_ret)
913
+ elif isinstance(obj_type, CustomClassType):
914
+ cls_sig = self.typeshed.resolve_class("builtins", obj_type.name)
915
+ if cls_sig and func.attr in cls_sig.methods:
916
+ return parse_type_annotation(cls_sig.methods[func.attr].return_type)
917
+ return None
918
+
919
+ def _infer_typeshed_lookup(self, target: str, func_name: str) -> PyType:
920
+ if target:
921
+ sig = self.typeshed.resolve_function(target)
922
+ if sig and sig.return_type and sig.return_type != "Any":
923
+ return parse_type_annotation(sig.return_type)
924
+
925
+ builtin_ret = self.typeshed.get_builtin_return_type(func_name)
926
+ if builtin_ret:
927
+ return parse_type_annotation(builtin_ret)
928
+
929
+ if func_name and self.typeshed.resolve_class("builtins", func_name):
930
+ return CustomClassType(func_name)
931
+
932
+ return AnyType()
933
+
934
+ def _infer_call(
935
+ self,
936
+ expr: ast.Call,
937
+ scope: dict[str, PyType],
938
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
939
+ ) -> PyType:
940
+ func_name, dotted_name = self._resolve_call_names(expr)
941
+ ctor = self._infer_constructor_call(
942
+ func_name, expr.args, scope, local_functions
943
+ )
944
+ if ctor is not None:
945
+ return ctor
946
+
947
+ if isinstance(expr.func, ast.Attribute):
948
+ meth = self._infer_attribute_method(expr.func, scope, local_functions)
949
+ if meth is not None:
950
+ return meth
951
+
952
+ if func_name in local_functions:
953
+ return local_functions[func_name][1]
954
+
955
+ return self._infer_typeshed_lookup(dotted_name or func_name, func_name)
956
+
957
+ def _infer_expr_type(
958
+ self,
959
+ expr: ast.AST | None,
960
+ scope: dict[str, PyType],
961
+ local_functions: dict[str, tuple[dict[str, PyType], PyType]],
962
+ ) -> PyType:
963
+ """Infer the PyType of an AST expression."""
964
+ if expr is None:
965
+ return NoneType()
966
+ if isinstance(expr, ast.Constant):
967
+ return self._infer_constant(expr)
968
+ if isinstance(expr, ast.Name):
969
+ return scope.get(expr.id, AnyType())
970
+ if isinstance(expr, (ast.List, ast.Set, ast.Dict)):
971
+ return self._infer_collection(expr, scope, local_functions)
972
+ if isinstance(expr, ast.BinOp):
973
+ return self._infer_binop(expr, scope, local_functions)
974
+ if isinstance(expr, ast.Compare):
975
+ return PrimitiveType("bool")
976
+ if isinstance(expr, ast.Call):
977
+ return self._infer_call(expr, scope, local_functions)
978
+ return AnyType()
979
+
980
+ def _is_nested_in_other_func(self, node: ast.AST, parent_func: ast.AST) -> bool:
981
+ """Verify if an AST node is contained in an inner/nested function definition."""
982
+ for child in ast.walk(parent_func):
983
+ if child is not parent_func and isinstance(
984
+ child, (ast.FunctionDef, ast.AsyncFunctionDef)
985
+ ):
986
+ for sub in ast.walk(child):
987
+ if sub is node:
988
+ return True
989
+ return False