Skip to content

Commit d06843a

Browse files
committed
Python: Support Python 3.13 type parameter defaults
1 parent 9c67bd6 commit d06843a

11 files changed

Lines changed: 190 additions & 28 deletions

File tree

rewrite-python/rewrite/pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ dependencies = [
3030
[project.optional-dependencies]
3131
dev = [
3232
"pytest>=8.0.0",
33+
"pytest-timeout>=2.0.0",
34+
"tox>=4.0.0",
3335
"black>=24.0.0",
3436
"ruff>=0.1.0",
3537
]
@@ -87,3 +89,13 @@ unresolved-reference = "ignore"
8789
testpaths = ["tests"]
8890
python_files = ["test_*.py", "*_test.py"]
8991
python_functions = ["test_*"]
92+
93+
[tool.tox]
94+
# Run version-specific tests: `tox -e py313`
95+
# Runs all tests; version-gated tests (e.g. tests/python/py313/) are
96+
# automatically skipped on incompatible Python versions via conftest.py.
97+
env_list = ["py311", "py312", "py313"]
98+
99+
[tool.tox.env_run_base]
100+
deps = ["pytest>=8.0.0", "pytest-timeout>=2.0.0"]
101+
commands = [["pytest", "tests/", "-v", "--timeout=60"]]

rewrite-python/rewrite/src/rewrite/java/visitor.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -848,18 +848,12 @@ def visit_method_invocation(self, method: j.MethodInvocation, p: P) -> J:
848848
if not isinstance(temp_expr, j.MethodInvocation):
849849
return temp_expr
850850
method = temp_expr
851-
method = method.replace(markers=self.visit_markers(method.markers, p))
852-
method = method.padding.replace(
853-
select=self.visit_right_padded(method.padding.select, p)
854-
)
855-
method = method.replace(
856-
type_parameters=self.visit_container(method.padding.type_parameters, p)
857-
)
858-
method = method.replace(
859-
name=self.visit_and_cast(method.name, j.Identifier, p)
860-
)
861851
method = method.replace(
862-
arguments=self.visit_container(method.padding.arguments, p)
852+
markers=self.visit_markers(method.markers, p),
853+
select=self.visit_right_padded(method.padding.select, p),
854+
type_parameters=self.visit_container(method.padding.type_parameters, p),
855+
name=self.visit_and_cast(method.name, j.Identifier, p),
856+
arguments=self.visit_container(method.padding.arguments, p),
863857
)
864858
return method
865859

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

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,13 +1492,33 @@ def visit_TryStar(self, node):
14921492
)
14931493

14941494
def visit_TypeVar(self, node) -> j.TypeParameter:
1495-
"""Visit a TypeVar (e.g., T or T: int)."""
1495+
"""Visit a TypeVar (e.g., T, T: int, T = int, or T: int = "default")."""
14961496
prefix = self.__whitespace()
14971497
name = self.__convert_name(node.name)
1498+
default = getattr(node, 'default_value', None)
14981499
if node.bound:
1500+
if default:
1501+
bounds = JContainer(
1502+
self.__source_before(':'),
1503+
[
1504+
self.__pad_right(self.__convert(node.bound), self.__source_before('=')),
1505+
self.__pad_right(self.__convert(default), Space.EMPTY),
1506+
],
1507+
Markers.EMPTY
1508+
)
1509+
else:
1510+
bounds = JContainer(
1511+
self.__source_before(':'),
1512+
[self.__pad_right(self.__convert(node.bound), Space.EMPTY)],
1513+
Markers.EMPTY
1514+
)
1515+
elif default:
14991516
bounds = JContainer(
1500-
self.__source_before(':'),
1501-
[self.__pad_right(self.__convert(node.bound), Space.EMPTY)],
1517+
self.__source_before('='),
1518+
[
1519+
self.__pad_right(j.Empty(random_id(), Space.EMPTY, Markers.EMPTY), Space.EMPTY),
1520+
self.__pad_right(self.__convert(default), Space.EMPTY),
1521+
],
15021522
Markers.EMPTY
15031523
)
15041524
else:
@@ -1514,7 +1534,7 @@ def visit_TypeVar(self, node) -> j.TypeParameter:
15141534
)
15151535

15161536
def visit_ParamSpec(self, node) -> j.TypeParameter:
1517-
"""Visit a ParamSpec (e.g., **P)."""
1537+
"""Visit a ParamSpec (e.g., **P or **P = [int, str])."""
15181538
prefix = self.__whitespace()
15191539
modifier = j.Modifier(
15201540
random_id(),
@@ -1525,18 +1545,30 @@ def visit_ParamSpec(self, node) -> j.TypeParameter:
15251545
[]
15261546
)
15271547
name = self.__convert_name(node.name)
1548+
default = getattr(node, 'default_value', None)
1549+
if default:
1550+
bounds = JContainer(
1551+
self.__source_before('='),
1552+
[
1553+
self.__pad_right(j.Empty(random_id(), Space.EMPTY, Markers.EMPTY), Space.EMPTY),
1554+
self.__pad_right(self.__convert(default), Space.EMPTY),
1555+
],
1556+
Markers.EMPTY
1557+
)
1558+
else:
1559+
bounds = None
15281560
return j.TypeParameter(
15291561
random_id(),
15301562
prefix,
15311563
Markers.EMPTY,
15321564
[], # annotations
15331565
[modifier],
15341566
name,
1535-
None # no bounds
1567+
bounds
15361568
)
15371569

15381570
def visit_TypeVarTuple(self, node) -> j.TypeParameter:
1539-
"""Visit a TypeVarTuple (e.g., *Ts)."""
1571+
"""Visit a TypeVarTuple (e.g., *Ts or *Ts = *tuple[int, ...])."""
15401572
prefix = self.__whitespace()
15411573
modifier = j.Modifier(
15421574
random_id(),
@@ -1547,14 +1579,26 @@ def visit_TypeVarTuple(self, node) -> j.TypeParameter:
15471579
[]
15481580
)
15491581
name = self.__convert_name(node.name)
1582+
default = getattr(node, 'default_value', None)
1583+
if default:
1584+
bounds = JContainer(
1585+
self.__source_before('='),
1586+
[
1587+
self.__pad_right(j.Empty(random_id(), Space.EMPTY, Markers.EMPTY), Space.EMPTY),
1588+
self.__pad_right(self.__convert(default), Space.EMPTY),
1589+
],
1590+
Markers.EMPTY
1591+
)
1592+
else:
1593+
bounds = None
15501594
return j.TypeParameter(
15511595
random_id(),
15521596
prefix,
15531597
Markers.EMPTY,
15541598
[], # annotations
15551599
[modifier],
15561600
name,
1557-
None # no bounds
1601+
bounds
15581602
)
15591603

15601604
def visit_TypeAlias(self, node):

rewrite-python/rewrite/src/rewrite/python/printer.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def _visit_comment(self, comment: Comment, p: PrintOutputCapture) -> None:
312312

313313
def _visit_markers(self, markers: Markers, p: PrintOutputCapture) -> Markers:
314314
"""Visit markers that need printing (like TrailingComma, Semicolon)."""
315-
for marker in markers.markers:
315+
for marker in markers._markers:
316316
if isinstance(marker, Semicolon):
317317
p.append(';')
318318
elif isinstance(marker, TrailingComma):
@@ -1064,7 +1064,7 @@ def _visit_comment(self, comment: Comment, p: PrintOutputCapture) -> None:
10641064

10651065
def _visit_markers(self, markers: Markers, p: PrintOutputCapture) -> Markers:
10661066
"""Visit markers that need printing (like TrailingComma, Semicolon)."""
1067-
for marker in markers.markers:
1067+
for marker in markers._markers:
10681068
self._visit_marker(marker, p)
10691069
return markers
10701070

@@ -1690,14 +1690,34 @@ def visit_throw(self, throw: 'j.Throw', p: PrintOutputCapture) -> J:
16901690
return throw
16911691

16921692
def visit_type_parameter(self, type_param: 'j.TypeParameter', p: PrintOutputCapture) -> J:
1693-
"""Visit a type parameter (Python 3.12+ PEP 695)."""
1693+
"""Visit a type parameter (Python 3.12+ PEP 695, 3.13+ PEP 696 defaults)."""
1694+
from rewrite.java import tree as j
16941695
self._before_syntax(type_param, p)
16951696
# Visit modifiers (for * and ** prefixes)
16961697
for mod in type_param.modifiers:
16971698
self.visit(mod, p)
16981699
self.visit(type_param.name, p)
1699-
# Visit bounds (for T: int style bounds in Python)
1700-
self._visit_container(":", type_param.padding.bounds, ",", "", p)
1700+
# Visit bounds: 1-element = constraint only, 2-element = [constraint, default]
1701+
bounds = type_param.padding.bounds
1702+
if bounds is not None:
1703+
elements = bounds.padding.elements
1704+
if len(elements) == 1:
1705+
# Legacy: only constraint, no default
1706+
self._visit_space(bounds.before, p)
1707+
p.append(":")
1708+
self._visit_right_padded(elements[0], p)
1709+
elif len(elements) == 2:
1710+
constraint = elements[0]
1711+
default = elements[1]
1712+
if not isinstance(constraint.element, j.Empty):
1713+
self._visit_space(bounds.before, p)
1714+
p.append(":")
1715+
self._visit_right_padded(constraint, p)
1716+
if not isinstance(default.element, j.Empty):
1717+
if isinstance(constraint.element, j.Empty):
1718+
self._visit_space(bounds.before, p)
1719+
p.append("=")
1720+
self._visit_right_padded(default, p)
17011721
self._after_syntax(type_param, p)
17021722
return type_param
17031723

rewrite-python/rewrite/src/rewrite/utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import inspect
21
from dataclasses import replace as dataclass_replace
32
from typing import Any, Callable, TypeVar, List, Union, cast
43
from uuid import UUID, uuid4
@@ -71,7 +70,10 @@ def list_map(fn: FnType[T], lst: List[T]) -> List[T]:
7170
changed = False
7271
mapped_lst = None
7372

74-
with_index = len(inspect.signature(fn).parameters) == 2
73+
arg_count = fn.__code__.co_argcount
74+
if hasattr(fn, '__self__'): # bound method — co_argcount includes self
75+
arg_count -= 1
76+
with_index = arg_count == 2
7577
for index, original in enumerate(lst):
7678
new = fn(original, index) if with_index else fn(original) # type: ignore
7779
if new is None:
@@ -96,7 +98,10 @@ def list_flat_map(fn: FlatMapFnType[T], lst: List[T]) -> List[T]:
9698
changed = False
9799
result: List[T] = []
98100

99-
with_index = len(inspect.signature(fn).parameters) == 2
101+
arg_count = fn.__code__.co_argcount
102+
if hasattr(fn, '__self__'): # bound method — co_argcount includes self
103+
arg_count -= 1
104+
with_index = arg_count == 2
100105
for index, item in enumerate(lst):
101106
new_items = fn(item, index) if with_index else fn(item) # type: ignore
102107
if new_items is None:

rewrite-python/rewrite/src/rewrite/visitor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ def default_value(self, tree: Optional[Tree], p: P) -> Optional[Tree]:
175175
def visit_markers(self, markers: Markers, p: P) -> Markers:
176176
if markers is None or markers is Markers.EMPTY:
177177
return Markers.EMPTY
178-
elif len(markers.markers) == 0:
178+
ms = markers._markers # bypass @property in hot path
179+
if len(ms) == 0:
179180
return markers
180-
return markers.replace(markers=list_map(lambda m: self.visit_marker(m, p), markers.markers))
181+
return markers.replace(markers=list_map(lambda m: self.visit_marker(m, p), ms))
181182

182183
def visit_marker(self, marker: Marker, p: P) -> Marker:
183184
return marker
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
3+
import pytest
4+
5+
6+
def pytest_runtest_setup(item):
7+
if sys.version_info < (3, 11):
8+
pytest.skip("requires Python 3.11+")
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
3+
import pytest
4+
5+
6+
def pytest_runtest_setup(item):
7+
if sys.version_info < (3, 12):
8+
pytest.skip("requires Python 3.12+")

rewrite-python/rewrite/tests/python/py313/__init__.py

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
3+
import pytest
4+
5+
6+
def pytest_runtest_setup(item):
7+
if sys.version_info < (3, 13):
8+
pytest.skip("requires Python 3.13+")

0 commit comments

Comments
 (0)