Skip to content

Commit ea79af4

Browse files
Add type attribution tests and fix method declaration/type hint types (#6794)
- Add type attribution tests to 13 existing parser test files covering method invocations, binary ops, type hints, collection literals, imports, field access, class instances, method declarations, async defs, for loops, unary ops, ternaries, and lambdas - Add `method_declaration_type()` to type_mapping.py to build JavaType.Method for function declarations using ty-types descriptor data with annotation fallback - Add type attribution to ParameterizedType nodes in type hint expressions - Fix pre-existing assign_test.py bug (simple_name access, FQN startswith) - Add typing.Text test to test_type_attribution.py - Bump ty-types dependency to >=0.0.19.dev20260223093555
1 parent 806843d commit ea79af4

18 files changed

Lines changed: 1017 additions & 10 deletions

rewrite-python/rewrite/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ requires-python = ">=3.10"
2424
dependencies = [
2525
"cbor2>=5.6.5",
2626
"more_itertools>=10.0.0",
27-
"ty-types>=0.0.18.dev0", # Type inference CLI for Python type attribution
27+
"ty-types>=0.0.19.dev20260223093555", # Type inference CLI for Python type attribution
2828
"parso>=0.7.1,<0.8", # Python 2/3 parser with CST support (0.8+ dropped Python 2.7 grammar)
2929
]
3030

rewrite-python/rewrite/src/rewrite/python/_parser_visitor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,7 +2182,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> j.MethodDeclaration:
21822182
None,
21832183
body,
21842184
None,
2185-
self.__as_method_type(self._type_mapping.type(node)),
2185+
self._type_mapping.method_declaration_type(node),
21862186
)
21872187

21882188
def __map_decorator(self, decorator) -> j.Annotation:
@@ -2871,7 +2871,7 @@ def __convert_type_mapper(self, node) -> Optional[TypeTree]:
28712871
enumerate(slices)],
28722872
Markers.EMPTY
28732873
),
2874-
None
2874+
self._type_mapping.type(node)
28752875
)
28762876
elif isinstance(node, ast.BinOp):
28772877
# Type unions using `|` was added in Python 3.10

rewrite-python/rewrite/src/rewrite/python/type_mapping.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,87 @@ def attribute_type_info(self, node: ast.Attribute,
618618
return expr_type, JavaType.Variable(_name=node.attr, _type=expr_type, _owner=receiver_type)
619619
return expr_type, None
620620

621+
def method_declaration_type(self, node: ast.FunctionDef) -> Optional[JavaType.Method]:
622+
"""Get the method type for a function/method declaration.
623+
624+
Builds a JavaType.Method from the function's type descriptor when
625+
available (parameters + returnType fields), falling back to resolving
626+
parameter annotations and return annotation individually via ty-types.
627+
628+
Args:
629+
node: The ast.FunctionDef or ast.AsyncFunctionDef node.
630+
631+
Returns:
632+
A JavaType.Method, or None if types cannot be determined.
633+
"""
634+
# First try: use structured data from the function descriptor
635+
type_id = self._lookup_type_id(node)
636+
if type_id is not None:
637+
descriptor = self._type_registry.get(type_id)
638+
if descriptor and descriptor.get('kind') in ('function', 'boundMethod'):
639+
# If the descriptor has parameters/returnType, use them directly
640+
params = descriptor.get('parameters')
641+
ret_id = descriptor.get('returnType')
642+
if params is not None or ret_id is not None:
643+
return self._method_from_function_descriptor(
644+
descriptor, node.name)
645+
646+
# Fallback: build from individual parameter/return annotation types
647+
param_names: List[str] = []
648+
param_types: List[JavaType] = []
649+
for arg in node.args.args:
650+
if arg.arg in ('self', 'cls'):
651+
continue
652+
param_names.append(arg.arg)
653+
if arg.annotation is not None:
654+
t = self.type(arg.annotation)
655+
param_types.append(t if t is not None else _UNKNOWN)
656+
else:
657+
param_types.append(_UNKNOWN)
658+
659+
return_type = None
660+
if node.returns is not None:
661+
return_type = self.type(node.returns)
662+
663+
if not param_names and return_type is None:
664+
return None
665+
666+
return JavaType.Method(
667+
_flags_bit_map=0,
668+
_declaring_type=None,
669+
_name=node.name,
670+
_return_type=return_type,
671+
_parameter_names=param_names if param_names else None,
672+
_parameter_types=param_types if param_types else None,
673+
)
674+
675+
def _method_from_function_descriptor(
676+
self, descriptor: Dict[str, Any], name: str
677+
) -> JavaType.Method:
678+
"""Build a JavaType.Method from a function descriptor with parameters/returnType."""
679+
param_names: List[str] = []
680+
param_types: List[JavaType] = []
681+
for param in descriptor.get('parameters', []):
682+
p_name = param.get('name', '')
683+
if p_name in ('self', 'cls'):
684+
continue
685+
param_names.append(p_name)
686+
param_types.append(self._resolve_param_type(param))
687+
688+
return_type = None
689+
ret_id = descriptor.get('returnType')
690+
if ret_id is not None:
691+
return_type = self._resolve_type(ret_id)
692+
693+
return JavaType.Method(
694+
_flags_bit_map=0,
695+
_declaring_type=None,
696+
_name=name,
697+
_return_type=return_type,
698+
_parameter_names=param_names if param_names else None,
699+
_parameter_types=param_types if param_types else None,
700+
)
701+
621702
def method_invocation_type(self, node: ast.Call) -> Optional[JavaType.Method]:
622703
"""Get the method type for a function/method call.
623704

rewrite-python/rewrite/tests/python/all/tree/assign_test.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,16 @@ def visit_assignment(self, assignment, p):
169169
if not assignment.type._type_parameters:
170170
errors.append("Parameterized._type_parameters is empty")
171171
elif isinstance(assignment.type, JavaType.Class):
172-
if assignment.type._fully_qualified_name != 'list':
173-
errors.append(f"Assignment.type fqn is '{assignment.type._fully_qualified_name}', expected 'list'")
172+
if not assignment.type._fully_qualified_name.startswith('list'):
173+
errors.append(f"Assignment.type fqn is '{assignment.type._fully_qualified_name}', expected to start with 'list'")
174174
else:
175175
errors.append(f"Assignment.type is {type(assignment.type).__name__}, expected Parameterized or Class")
176176
return assignment
177177

178178
def visit_method_invocation(self, method, p):
179179
if not isinstance(method, MethodInvocation):
180180
return method
181-
if method.simple_name != 'split':
181+
if method.name.simple_name != 'split':
182182
return method
183183
# method_type should be populated
184184
if method.method_type is None:
@@ -194,11 +194,11 @@ def visit_method_invocation(self, method, p):
194194
if method.method_type is not None and method.method_type._return_type is not None:
195195
rt = method.method_type._return_type
196196
if isinstance(rt, Parameterized):
197-
if rt._type._fully_qualified_name != 'list':
198-
errors.append(f"return_type fqn is '{rt._type._fully_qualified_name}', expected 'list'")
197+
if not rt._type._fully_qualified_name.startswith('list'):
198+
errors.append(f"return_type fqn is '{rt._type._fully_qualified_name}', expected to start with 'list'")
199199
elif isinstance(rt, JavaType.Class):
200-
if rt._fully_qualified_name != 'list':
201-
errors.append(f"return_type fqn is '{rt._fully_qualified_name}', expected 'list'")
200+
if not rt._fully_qualified_name.startswith('list'):
201+
errors.append(f"return_type fqn is '{rt._fully_qualified_name}', expected to start with 'list'")
202202
else:
203203
errors.append(f"return_type is {type(rt).__name__}, expected Parameterized or Class")
204204
return method

rewrite-python/rewrite/tests/python/all/tree/binary_test.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
import shutil
2+
13
import pytest
24

5+
from rewrite.java.support_types import JavaType
6+
from rewrite.java.tree import Binary
7+
from rewrite.python.tree import CompilationUnit
8+
from rewrite.python.visitor import PythonVisitor
39
from rewrite.test import RecipeSpec, python
410

11+
requires_ty_cli = pytest.mark.skipif(
12+
shutil.which('ty-types') is None,
13+
reason="ty-types CLI is not installed"
14+
)
15+
516

617
def test_bool_ops():
718
# language=python
@@ -95,3 +106,93 @@ def test_multiline_tuple_comparison():
95106
, False) is not None
96107
""",
97108
),)
109+
110+
111+
@requires_ty_cli
112+
def test_arithmetic_type_attribution():
113+
"""Verify that 1 + 2 has type Int."""
114+
errors = []
115+
116+
def check_types(source_file):
117+
assert isinstance(source_file, CompilationUnit)
118+
119+
class TypeChecker(PythonVisitor):
120+
def visit_binary(self, binary, p):
121+
if not isinstance(binary, Binary):
122+
return binary
123+
if binary.operator != Binary.Type.Addition:
124+
return binary
125+
if binary.type is None:
126+
errors.append("Binary(Addition).type is None")
127+
elif binary.type != JavaType.Primitive.Int:
128+
errors.append(f"Binary(Addition).type is {binary.type}, expected Primitive.Int")
129+
return binary
130+
131+
TypeChecker().visit(source_file, None)
132+
133+
# language=python
134+
RecipeSpec(type_attribution=True).rewrite_run(python(
135+
"x = 1 + 2",
136+
after_recipe=check_types,
137+
))
138+
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
139+
140+
141+
@requires_ty_cli
142+
def test_comparison_type_attribution():
143+
"""Verify that 1 < 2 has type Boolean."""
144+
errors = []
145+
146+
def check_types(source_file):
147+
assert isinstance(source_file, CompilationUnit)
148+
149+
class TypeChecker(PythonVisitor):
150+
def visit_binary(self, binary, p):
151+
if not isinstance(binary, Binary):
152+
return binary
153+
if binary.operator != Binary.Type.LessThan:
154+
return binary
155+
if binary.type is None:
156+
errors.append("Binary(LessThan).type is None")
157+
elif binary.type != JavaType.Primitive.Boolean:
158+
errors.append(f"Binary(LessThan).type is {binary.type}, expected Primitive.Boolean")
159+
return binary
160+
161+
TypeChecker().visit(source_file, None)
162+
163+
# language=python
164+
RecipeSpec(type_attribution=True).rewrite_run(python(
165+
"x = 1 < 2",
166+
after_recipe=check_types,
167+
))
168+
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
169+
170+
171+
@requires_ty_cli
172+
def test_boolean_op_type_attribution():
173+
"""Verify that True and False has type Boolean."""
174+
errors = []
175+
176+
def check_types(source_file):
177+
assert isinstance(source_file, CompilationUnit)
178+
179+
class TypeChecker(PythonVisitor):
180+
def visit_binary(self, binary, p):
181+
if not isinstance(binary, Binary):
182+
return binary
183+
if binary.operator != Binary.Type.And:
184+
return binary
185+
if binary.type is None:
186+
errors.append("Binary(And).type is None")
187+
elif binary.type != JavaType.Primitive.Boolean:
188+
errors.append(f"Binary(And).type is {binary.type}, expected Primitive.Boolean")
189+
return binary
190+
191+
TypeChecker().visit(source_file, None)
192+
193+
# language=python
194+
RecipeSpec(type_attribution=True).rewrite_run(python(
195+
"x = True and False",
196+
after_recipe=check_types,
197+
))
198+
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)

rewrite-python/rewrite/tests/python/all/tree/class_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
import shutil
2+
3+
import pytest
4+
5+
from rewrite.java.support_types import JavaType
6+
from rewrite.java.tree import Assignment
7+
from rewrite.python.tree import CompilationUnit
8+
from rewrite.python.visitor import PythonVisitor
19
from rewrite.test import RecipeSpec, python
210

11+
requires_ty_cli = pytest.mark.skipif(
12+
shutil.which('ty-types') is None,
13+
reason="ty-types CLI is not installed"
14+
)
15+
316

417
def test_empty():
518
# language=python
@@ -110,3 +123,39 @@ class C3(Generic[T], metaclass=type, *[str]):
110123
...
111124
"""
112125
))
126+
127+
128+
@requires_ty_cli
129+
def test_class_instance_type_attribution():
130+
"""Verify that x = Foo() assigns a type with fqn 'Foo'."""
131+
errors = []
132+
133+
def check_types(source_file):
134+
assert isinstance(source_file, CompilationUnit)
135+
136+
class TypeChecker(PythonVisitor):
137+
def visit_assignment(self, assignment, p):
138+
if not isinstance(assignment, Assignment):
139+
return assignment
140+
if assignment.type is None:
141+
errors.append("Assignment.type is None for Foo()")
142+
elif isinstance(assignment.type, JavaType.Class):
143+
if assignment.type._fully_qualified_name != 'Foo':
144+
errors.append(f"Assignment.type fqn is '{assignment.type._fully_qualified_name}', expected 'Foo'")
145+
else:
146+
# Accept any non-None type
147+
pass
148+
return assignment
149+
150+
TypeChecker().visit(source_file, None)
151+
152+
# language=python
153+
RecipeSpec(type_attribution=True).rewrite_run(python(
154+
"""\
155+
class Foo:
156+
pass
157+
x = Foo()
158+
""",
159+
after_recipe=check_types,
160+
))
161+
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)

0 commit comments

Comments
 (0)