Skip to content

Commit 727aed2

Browse files
Python: Add type attribution tests, use moduleName for module-qualified FQNs, and fix RemoveImport shadowing (#6798)
* Add type attribution tests and fix method declaration/type hint types - 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 * Add parameter field_type attribution and call-site type arguments - Add param_type_info() to PythonTypeMapping for function parameter Identifier nodes to get JavaType.Variable field_type - Update __convert_name() and map_arg() to flow field_type through to J.Identifier and NamedVariable - Use call signature returnTypeId for call-site-specific return types (e.g. int for identity(42) instead of generic T) - Populate _declared_formal_type_names on method invocation types from function descriptor type parameters - Bump ty-types to 0.0.19.dev20260223102528 for callSignature typeArguments/returnTypeId support * Use moduleName from ty-types for module-qualified FQNs classLiteral types now use moduleName to build proper FQNs (e.g. mymodule.MyClass instead of bare MyClass), and bare function calls get a declaring type from their module. Bumps ty-types to 0.0.19.dev20260223122104. * Address review feedback on type attribution tests - Extract _ty_types_has_module_name() into shared _markers.py module to eliminate duplication and reduce subprocess overhead - Fix temp file leak by adding os.unlink() in finally block - Fix test_class_instance_type_attribution to accept module-qualified FQNs (endsWith 'Foo' instead of exact match) - Gate from-import tests with @requires_module_name since bare function calls need moduleName for declaring type resolution - Accept both posixpath and ntpath in os.path test for Windows compat * Remove skip markers and fix UUID temp filenames for ty-types Since ty-types minimum version is pinned in pyproject.toml, the @requires_ty_cli and @requires_module_name skip markers are unnecessary. Remove them and the shared _markers.py module. Also prefix UUID-based temp filenames with underscore to ensure they are valid Python module names — ty-types omits moduleName when the filename starts with a digit, causing non-deterministic test behavior. * Use type attribution in RemoveImport to detect shadowed locals RemoveImport._is_referenced now skips identifiers inside function scopes that have field_type set, as these are local variables shadowing the imported name rather than actual uses of the import. This prevents keeping unused imports when a local variable happens to share the same name. Also fix test_type_attribution assertions to accept module-qualified FQNs (e.g. 'test.Greeter' instead of bare 'Greeter') since the moduleName improvements now produce module-prefixed names.
1 parent f7120ec commit 727aed2

21 files changed

Lines changed: 192 additions & 178 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.19.dev20260223102528", # Type inference CLI for Python type attribution
27+
"ty-types>=0.0.19.dev20260223122104", # 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/remove_import.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from rewrite.java import J
2222
from rewrite.java.support_types import JContainer, JRightPadded
23-
from rewrite.java.tree import Empty, FieldAccess, Identifier, Import, Space
23+
from rewrite.java.tree import Empty, FieldAccess, Identifier, Import, MethodDeclaration, Space
2424
from rewrite.markers import Markers
2525
from rewrite.python.tree import CompilationUnit, MultiImport
2626
from rewrite.python.visitor import PythonVisitor
@@ -129,6 +129,11 @@ def visit_multi_import(self, multi: MultiImport, p) -> J:
129129

130130
def visit_identifier(self, ident: Identifier, p) -> J:
131131
if not self.in_import:
132+
# Inside a function scope, identifiers with field_type are
133+
# local variables (shadowing the import), not actual uses.
134+
if self.cursor.first_enclosing(MethodDeclaration) is not None:
135+
if ident.field_type is not None:
136+
return ident
132137
used.add(ident.simple_name)
133138
return ident
134139

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,12 @@ def _descriptor_to_java_type(self, descriptor: Dict[str, Any]) -> Optional[JavaT
446446

447447
elif kind == 'classLiteral':
448448
class_name = descriptor.get('className', '')
449-
class_type = self._create_class_type(class_name)
449+
module_name = descriptor.get('moduleName')
450+
if module_name and module_name != 'builtins':
451+
fqn = f"{module_name}.{class_name}"
452+
else:
453+
fqn = class_name
454+
class_type = self._create_class_type(fqn)
450455

451456
# Infer Kind from supertypes before resolving them
452457
supertypes = descriptor.get('supertypes', [])
@@ -881,6 +886,10 @@ def _get_declaring_type(self, node: ast.Call) -> Optional[JavaType.FullyQualifie
881886
kind = descriptor.get('kind')
882887
if kind == 'module':
883888
return self._create_class_type(descriptor.get('moduleName', ''))
889+
elif kind in ('function', 'boundMethod'):
890+
module_name = descriptor.get('moduleName')
891+
if module_name and module_name != 'builtins':
892+
return self._create_class_type(module_name)
884893

885894
return self._infer_declaring_type_from_ast(node)
886895

rewrite-python/rewrite/src/rewrite/test/rewrite_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def _parse(self, specs: List[SourceSpec]) -> List[Tuple[SourceSpec, SourceFile]]
227227
continue
228228

229229
# Determine source path
230-
source_path = spec.path or Path(f"{uuid4().hex}.{spec.ext}")
230+
source_path = spec.path or Path(f"_{uuid4().hex}.{spec.ext}")
231231

232232
# Parse the source
233233
source = dedent(spec.before)

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import shutil
2-
3-
import pytest
4-
51
from rewrite.java.support_types import JavaType
62
from rewrite.java.tree import Assignment, Identifier, MethodInvocation
73
from rewrite.python.tree import CompilationUnit
@@ -10,11 +6,6 @@
106

117
Parameterized = JavaType.Parameterized
128

13-
requires_ty_cli = pytest.mark.skipif(
14-
shutil.which('ty-types') is None,
15-
reason="ty-types CLI is not installed"
16-
)
17-
189

1910
def test_assign():
2011
# language=python
@@ -112,7 +103,6 @@ def test_assign_op():
112103
RecipeSpec().rewrite_run(python("a @= 1"))
113104

114105

115-
@requires_ty_cli
116106
def test_assign_type_attribution():
117107
"""Verify that type attribution is populated on assignment AST nodes."""
118108
errors = []
@@ -146,7 +136,6 @@ def visit_assignment(self, assignment, p):
146136
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
147137

148138

149-
@requires_ty_cli
150139
def test_assign_method_call_type_attribution():
151140
"""Verify type attribution on an assignment from a method call like str.split()."""
152141
errors = []

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
1-
import shutil
2-
3-
import pytest
4-
51
from rewrite.java.support_types import JavaType
62
from rewrite.java.tree import Binary
73
from rewrite.python.tree import CompilationUnit
84
from rewrite.python.visitor import PythonVisitor
95
from rewrite.test import RecipeSpec, python
106

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

178
def test_bool_ops():
189
# language=python
@@ -108,7 +99,6 @@ def test_multiline_tuple_comparison():
10899
),)
109100

110101

111-
@requires_ty_cli
112102
def test_arithmetic_type_attribution():
113103
"""Verify that 1 + 2 has type Int."""
114104
errors = []
@@ -138,7 +128,6 @@ def visit_binary(self, binary, p):
138128
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
139129

140130

141-
@requires_ty_cli
142131
def test_comparison_type_attribution():
143132
"""Verify that 1 < 2 has type Boolean."""
144133
errors = []
@@ -168,7 +157,6 @@ def visit_binary(self, binary, p):
168157
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
169158

170159

171-
@requires_ty_cli
172160
def test_boolean_op_type_attribution():
173161
"""Verify that True and False has type Boolean."""
174162
errors = []

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

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import shutil
2-
3-
import pytest
1+
from pathlib import Path
42

53
from rewrite.java.support_types import JavaType
6-
from rewrite.java.tree import Assignment, Identifier
4+
from rewrite.java.tree import Assignment, ClassDeclaration, Identifier
75
from rewrite.python.tree import CompilationUnit
86
from rewrite.python.visitor import PythonVisitor
97
from rewrite.test import RecipeSpec, python
108

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

1710
def test_empty():
1811
# language=python
@@ -125,7 +118,6 @@ class C3(Generic[T], metaclass=type, *[str]):
125118
))
126119

127120

128-
@requires_ty_cli
129121
def test_generic_class_type_params():
130122
"""Verify type parameters on a generic class like class Box[T]."""
131123
errors = []
@@ -174,9 +166,43 @@ def __init__(self, value: T) -> None:
174166
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
175167

176168

177-
@requires_ty_cli
169+
def test_class_literal_module_qualified_fqn():
170+
"""Verify that a class defined in a module gets a module-qualified FQN on its classLiteral type."""
171+
errors = []
172+
173+
def check_types(source_file):
174+
assert isinstance(source_file, CompilationUnit)
175+
176+
class TypeChecker(PythonVisitor):
177+
def visit_class_declaration(self, class_decl, p):
178+
if not isinstance(class_decl, ClassDeclaration):
179+
return class_decl
180+
cd_type = class_decl.type
181+
if cd_type is None:
182+
errors.append("ClassDeclaration.type is None for MyClass")
183+
elif isinstance(cd_type, JavaType.Class):
184+
fqn = cd_type._fully_qualified_name
185+
# The FQN should contain a module prefix (not just bare 'MyClass')
186+
if '.' not in fqn:
187+
errors.append(f"ClassDeclaration.type fqn '{fqn}' has no module prefix, expected '<module>.MyClass'")
188+
return class_decl
189+
190+
TypeChecker().visit(source_file, None)
191+
192+
# language=python
193+
RecipeSpec(type_attribution=True).rewrite_run(python(
194+
"""\
195+
class MyClass:
196+
pass
197+
""",
198+
path=Path("a.py"),
199+
after_recipe=check_types,
200+
))
201+
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
202+
203+
178204
def test_class_instance_type_attribution():
179-
"""Verify that x = Foo() assigns a type with fqn 'Foo'."""
205+
"""Verify that x = Foo() assigns a type with fqn ending in 'Foo'."""
180206
errors = []
181207

182208
def check_types(source_file):
@@ -189,8 +215,10 @@ def visit_assignment(self, assignment, p):
189215
if assignment.type is None:
190216
errors.append("Assignment.type is None for Foo()")
191217
elif isinstance(assignment.type, JavaType.Class):
192-
if assignment.type._fully_qualified_name != 'Foo':
193-
errors.append(f"Assignment.type fqn is '{assignment.type._fully_qualified_name}', expected 'Foo'")
218+
fqn = assignment.type._fully_qualified_name
219+
# Accept 'Foo' or '<module>.Foo' (module-qualified with newer ty-types)
220+
if not fqn.endswith('Foo'):
221+
errors.append(f"Assignment.type fqn is '{fqn}', expected to end with 'Foo'")
194222
else:
195223
# Accept any non-None type
196224
pass

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import shutil
21
from typing import cast
32

4-
import pytest
5-
63
from rewrite.java import MethodDeclaration, Return
74
from rewrite.java.support_types import JavaType
85
from rewrite.java.tree import Assignment
@@ -12,11 +9,6 @@
129

1310
Parameterized = JavaType.Parameterized
1411

15-
requires_ty_cli = pytest.mark.skipif(
16-
shutil.which('ty-types') is None,
17-
reason="ty-types CLI is not installed"
18-
)
19-
2012

2113
def test_empty_tuple():
2214
# language=python
@@ -107,7 +99,6 @@ def test_list_of_tuples_with_double_parens():
10799
"""))
108100

109101

110-
@requires_ty_cli
111102
def test_list_literal_type_attribution():
112103
"""Verify that [1, 2, 3] has type list."""
113104
errors = []
@@ -139,7 +130,6 @@ def visit_assignment(self, assignment, p):
139130
assert not errors, "Type attribution errors:\n" + "\n".join(f" - {e}" for e in errors)
140131

141132

142-
@requires_ty_cli
143133
def test_dict_literal_type_attribution():
144134
"""Verify that {"a": 1} has type dict."""
145135
errors = []

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
1-
import shutil
2-
3-
import pytest
4-
51
from rewrite.java.support_types import JavaType
62
from rewrite.java.tree import MethodDeclaration
73
from rewrite.python.tree import CompilationUnit
84
from rewrite.python.visitor import PythonVisitor
95
from rewrite.test import RecipeSpec, python
106

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

178
def test_async_def():
189
# language=python
@@ -24,7 +15,6 @@ async def main():
2415
))
2516

2617

27-
@requires_ty_cli
2818
def test_async_def_type_attribution():
2919
"""Verify that async def main() -> int has a method_type with a return_type.
3020

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
import shutil
2-
3-
import pytest
4-
51
from rewrite.java.tree import FieldAccess
62
from rewrite.python.tree import CompilationUnit
73
from rewrite.python.visitor import PythonVisitor
84
from rewrite.test import RecipeSpec, python
95

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

167
# noinspection PyUnresolvedReferences
178
def test_attribute():
@@ -25,7 +16,6 @@ def test_nested_attribute():
2516
RecipeSpec().rewrite_run(python("a = foo.bar.baz"))
2617

2718

28-
@requires_ty_cli
2919
def test_field_access_type_attribution():
3020
"""Verify that os.path has a non-None FieldAccess.type."""
3121
errors = []

0 commit comments

Comments
 (0)