Skip to content

Commit 8c6b95e

Browse files
committed
Always enable ty type attribution for template parsing
TemplateEngine._parse_code previously only used TyTypesClient when dependencies were specified. This meant patterns for stdlib functions (e.g. json.dumps) were parsed without type attribution, preventing Layer 2 FQN matching. Now _parse_code always attempts ty: with dependencies it uses the cached DependencyWorkspace; without, a temporary directory suffices for stdlib and already-installed packages. Switched FQN integration tests from six (ty 0.0.19 cannot resolve it) to stdlib json.dumps which ty resolves consistently.
1 parent 2b5ddbb commit 8c6b95e

2 files changed

Lines changed: 74 additions & 88 deletions

File tree

rewrite-python/rewrite/src/rewrite/python/template/engine.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,10 @@ def _parse_code(cls, code: str, options: Optional[TemplateOptions] = None) -> 'C
228228
"""
229229
Parse Python code into an LST CompilationUnit.
230230
231-
When *options* specifies dependencies, a cached workspace is created
232-
and ``TyTypesClient`` is used for type attribution during parsing.
231+
Always attempts type attribution via ``TyTypesClient``. When
232+
*options* specifies dependencies, a cached virtualenv workspace is
233+
used; otherwise a temporary directory suffices (enough for stdlib
234+
and already-installed packages).
233235
234236
Args:
235237
code: Python source code.
@@ -244,27 +246,33 @@ def _parse_code(cls, code: str, options: Optional[TemplateOptions] = None) -> 'C
244246

245247
ty_client = None
246248
tmp_file = None
249+
owns_workspace = False
250+
workspace = None
247251
try:
248-
if options and options.dependencies:
249-
try:
250-
import tempfile as _tmpmod
251-
from .dependency_workspace import DependencyWorkspace
252-
from rewrite.python.ty_client import TyTypesClient
252+
try:
253+
import tempfile as _tmpmod
254+
from rewrite.python.ty_client import TyTypesClient
253255

256+
if options and options.dependencies:
257+
from .dependency_workspace import DependencyWorkspace
254258
workspace = DependencyWorkspace.get_or_create(options.dependencies)
255-
ty_client = TyTypesClient()
256-
ty_client.initialize(workspace)
257-
258-
# ty needs a real file path for type resolution
259-
fd, tmp_file = _tmpmod.mkstemp(suffix=".py", dir=workspace)
260-
try:
261-
os.write(fd, code.encode())
262-
finally:
263-
os.close(fd)
264-
except (ImportError, RuntimeError):
265-
# uv or ty-types not available — parse without type attribution
266-
ty_client = None
267-
tmp_file = None
259+
else:
260+
workspace = _tmpmod.mkdtemp()
261+
owns_workspace = True
262+
263+
ty_client = TyTypesClient()
264+
ty_client.initialize(workspace)
265+
266+
# ty needs a real file path for type resolution
267+
fd, tmp_file = _tmpmod.mkstemp(suffix=".py", dir=workspace)
268+
try:
269+
os.write(fd, code.encode())
270+
finally:
271+
os.close(fd)
272+
except (ImportError, RuntimeError):
273+
# ty-types not available — parse without type attribution
274+
ty_client = None
275+
tmp_file = None
268276

269277
visitor = ParserVisitor(code, file_path=tmp_file, ty_client=ty_client)
270278
return visitor.visit(tree)
@@ -276,6 +284,9 @@ def _parse_code(cls, code: str, options: Optional[TemplateOptions] = None) -> 'C
276284
os.unlink(tmp_file)
277285
except OSError:
278286
pass
287+
if owns_workspace and workspace is not None:
288+
import shutil
289+
shutil.rmtree(workspace, ignore_errors=True)
279290

280291
@classmethod
281292
def _extract_from_wrapper(cls, cu: 'CompilationUnit', is_expression: bool) -> J:

rewrite-python/rewrite/tests/python/template/test_template.py

Lines changed: 43 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
"""Tests for Template class."""
1616

17-
import shutil
1817
from typing import Any, Optional
1918
from uuid import uuid4
2019

@@ -29,7 +28,7 @@
2928
from rewrite.python.template.engine import TemplateEngine
3029
from rewrite.python.template.replacement import maybe_parenthesize
3130
from rewrite.python.visitor import PythonVisitor
32-
from rewrite.test import RecipeSpec, python, pyproject, uv
31+
from rewrite.test import RecipeSpec, python
3332
from rewrite.visitor import Cursor
3433

3534

@@ -660,37 +659,36 @@ def test_builder_with_dependencies(self):
660659
assert tmpl._options.dependencies == (("requests", "2.31.0"),)
661660

662661

663-
@pytest.mark.skipif(not shutil.which("uv"), reason="uv not installed")
664662
class TestTypeAttributedPatternMatching:
665663
"""Integration tests for pattern matching with type attribution.
666664
667-
These tests verify that uv() + pyproject() + pattern(dependencies=...)
668-
produce type-attributed ASTs that the 3-layer comparator can use for
669-
FQN-based semantic matching.
665+
These tests use stdlib ``json.dumps`` (no external dependencies) to verify
666+
that type-attributed ASTs enable FQN-based semantic matching in the 3-layer
667+
comparator. ``ty`` consistently resolves ``json.dumps`` to
668+
``declaring_fqn=json`` regardless of import style.
670669
"""
671670

672671
def test_fqn_match_different_import_styles(self):
673672
"""Pattern matches code that uses a different import style via FQN.
674673
675-
Code uses ``from six import ensure_str; ensure_str(x)`` (bare name).
676-
Pattern uses ``six.ensure_str({val})`` (qualified name).
674+
Code uses ``from json import dumps; dumps(x)`` (bare name).
675+
Pattern uses ``json.dumps({val})`` (qualified name).
677676
Without type attribution, these would NOT structurally match because
678-
the select (None vs Identifier("six")) differs. With type attribution,
679-
Layer 2 FQN matching detects both resolve to ``six.ensure_str`` and
677+
the select (None vs Identifier("json")) differs. With type attribution,
678+
Layer 2 FQN matching detects both resolve to ``json.dumps`` and
680679
skips the select comparison.
681680
"""
682681
val = capture('val')
683682
pat = pattern(
684-
f"six.ensure_str({val})",
685-
imports=["import six"],
686-
dependencies={"six": "1.17.0"},
683+
f"json.dumps({val})",
684+
context=["import json"],
687685
)
688686
tmpl = template(f"str({val})")
689687

690-
class ReplaceSixEnsureStr(Recipe):
688+
class ReplaceJsonDumps(Recipe):
691689
@property
692690
def name(self):
693-
return "test.ReplaceSixEnsureStr"
691+
return "test.ReplaceJsonDumps"
694692

695693
@property
696694
def display_name(self):
@@ -710,53 +708,41 @@ def visit_method_invocation(self, method, p):
710708
return method
711709
return Visitor()
712710

713-
spec = RecipeSpec(recipe=ReplaceSixEnsureStr())
711+
spec = RecipeSpec(recipe=ReplaceJsonDumps())
714712
spec.rewrite_run(
715-
*uv(
716-
pyproject(
717-
"""
718-
[project]
719-
name = "test"
720-
version = "0.0.0"
721-
requires-python = ">=3.10"
722-
dependencies = ["six==1.17.0"]
723-
"""
724-
),
725-
python(
726-
"""
727-
from six import ensure_str
728-
x = ensure_str("hello")
729-
""",
730-
"""
731-
from six import ensure_str
732-
x = str("hello")
733-
""",
734-
),
735-
)
713+
python(
714+
"""
715+
from json import dumps
716+
x = dumps({"key": "value"})
717+
""",
718+
"""
719+
from json import dumps
720+
x = str({"key": "value"})
721+
""",
722+
),
736723
)
737724

738725
def test_fqn_match_with_aliased_import(self):
739726
"""Pattern matches code that uses an aliased import via FQN.
740727
741-
Code uses ``import six as s; s.ensure_str(x)`` (aliased select ``s``).
742-
Pattern uses ``six.ensure_str({val})`` (canonical select ``six``).
743-
Without type attribution, ``Identifier("s")`` != ``Identifier("six")``
728+
Code uses ``import json as j; j.dumps(x)`` (aliased select ``j``).
729+
Pattern uses ``json.dumps({val})`` (canonical select ``json``).
730+
Without type attribution, ``Identifier("j")`` != ``Identifier("json")``
744731
so the structural comparison would fail. With type attribution,
745-
Layer 2 FQN matching detects both resolve to ``six.ensure_str``
732+
Layer 2 FQN matching detects both resolve to ``json.dumps``
746733
and skips the select comparison entirely.
747734
"""
748735
val = capture('val')
749736
pat = pattern(
750-
f"six.ensure_str({val})",
751-
imports=["import six"],
752-
dependencies={"six": "1.17.0"},
737+
f"json.dumps({val})",
738+
context=["import json"],
753739
)
754740
tmpl = template(f"str({val})")
755741

756-
class ReplaceSixEnsureStr(Recipe):
742+
class ReplaceJsonDumps(Recipe):
757743
@property
758744
def name(self):
759-
return "test.ReplaceSixEnsureStr"
745+
return "test.ReplaceJsonDumps"
760746

761747
@property
762748
def display_name(self):
@@ -776,27 +762,16 @@ def visit_method_invocation(self, method, p):
776762
return method
777763
return Visitor()
778764

779-
spec = RecipeSpec(recipe=ReplaceSixEnsureStr())
765+
spec = RecipeSpec(recipe=ReplaceJsonDumps())
780766
spec.rewrite_run(
781-
*uv(
782-
pyproject(
783-
"""
784-
[project]
785-
name = "test"
786-
version = "0.0.0"
787-
requires-python = ">=3.10"
788-
dependencies = ["six==1.17.0"]
789-
"""
790-
),
791-
python(
792-
"""
793-
import six as s
794-
x = s.ensure_str("hello")
795-
""",
796-
"""
797-
import six as s
798-
x = str("hello")
799-
""",
800-
),
801-
)
767+
python(
768+
"""
769+
import json as j
770+
x = j.dumps({"key": "value"})
771+
""",
772+
"""
773+
import json as j
774+
x = str({"key": "value"})
775+
""",
776+
),
802777
)

0 commit comments

Comments
 (0)