Skip to content

Commit bada460

Browse files
committed
downlevel pep 696 type-param defaults on 3.12 targets, hard error for reified
1 parent f8ccba3 commit bada460

6 files changed

Lines changed: 274 additions & 10 deletions

File tree

‎crates/by_transforms/src/transforms/generics.rs‎

Lines changed: 148 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ impl<'src> GenericPolyfill<'src> {
125125
}
126126
}
127127

128+
/// whether the target can keep this parameter list as native syntax:
129+
/// pep 695 lists need 3.12+, and a pep 696 default (`[T = int]`) bumps
130+
/// the requirement to 3.13. a defaulted list on a 3.12 target polyfills
131+
/// the declaration exactly like pre-3.12 code
132+
fn supports_native_type_params(&self, params: &[TypeParam]) -> bool {
133+
let required = if params.iter().any(|p| p.default().is_some()) {
134+
PythonVersion::PY313
135+
} else {
136+
PythonVersion::PY312
137+
};
138+
self.config.min_version >= required
139+
}
140+
128141
/// Pick a unique mangled name for a `TypeVar`.
129142
///
130143
/// First emission of a given source name returns the standard mangled
@@ -439,8 +452,8 @@ impl<'src> GenericPolyfill<'src> {
439452
self.parameters_targets
440453
.insert(class.name.id.as_str().to_owned());
441454
}
442-
// PEP 695 class type params are native syntax in 3.12+
443-
if self.config.min_version >= PythonVersion::PY312 {
455+
// PEP 695 class type params are native syntax in 3.12+ (3.13+ with defaults)
456+
if self.supports_native_type_params(&tp.type_params) {
444457
self.strip_constraints_keyword(&tp.type_params);
445458
return;
446459
}
@@ -533,8 +546,8 @@ impl<'src> GenericPolyfill<'src> {
533546
self.parameters_targets
534547
.insert(func.name.id.as_str().to_owned());
535548
}
536-
// PEP 695 function type params are native syntax in 3.12+
537-
if self.config.min_version >= PythonVersion::PY312 {
549+
// PEP 695 function type params are native syntax in 3.12+ (3.13+ with defaults)
550+
if self.supports_native_type_params(&tp.type_params) {
538551
self.strip_constraints_keyword(&tp.type_params);
539552
return;
540553
}
@@ -587,7 +600,11 @@ impl<'src> GenericPolyfill<'src> {
587600
fn process_type_alias(&mut self, alias: &StmtTypeAlias) {
588601
// `type Point = tuple[float, float]`
589602
// → `Point = TypeAliasType("Point", tuple[float, float])`
590-
if self.config.min_version >= PythonVersion::PY312 {
603+
let params = alias
604+
.type_params
605+
.as_deref()
606+
.map_or(&[][..], |tp| tp.type_params.as_slice());
607+
if self.supports_native_type_params(params) {
591608
if let Some(tp) = &alias.type_params {
592609
self.strip_constraints_keyword(&tp.type_params);
593610
}
@@ -1308,6 +1325,132 @@ mod tests {
13081325
check_at(src, expected, PythonVersion::PY314);
13091326
}
13101327

1328+
#[test]
1329+
fn function_default_downleveled_on_312() {
1330+
// pep 696 defaults are 3.13-only syntax: a defaulted list on a 3.12
1331+
// target polyfills like pre-3.12 code instead of passing through
1332+
check_at(
1333+
indoc! {"
1334+
def f[T = int](x: T) -> T:
1335+
return x
1336+
"},
1337+
indoc! {"
1338+
from typing_extensions import TypeVar
1339+
_T = TypeVar(\"_T\", default=int)
1340+
def f(x: _T) -> _T:
1341+
return x
1342+
"},
1343+
PythonVersion::PY312,
1344+
);
1345+
}
1346+
1347+
#[test]
1348+
fn function_default_unchanged_on_313() {
1349+
let src = indoc! {"
1350+
def f[T = int](x: T) -> T:
1351+
return x
1352+
"};
1353+
check_at(src, src, PythonVersion::PY313);
1354+
check_at(src, src, PythonVersion::PY314);
1355+
}
1356+
1357+
#[test]
1358+
fn class_default_downleveled_on_312() {
1359+
check_at(
1360+
"class A[T = int]: ...\n",
1361+
indoc! {"
1362+
from typing import Generic
1363+
from typing_extensions import TypeVar
1364+
_T = TypeVar(\"_T\", default=int)
1365+
class A(Generic[_T]): ...
1366+
"},
1367+
PythonVersion::PY312,
1368+
);
1369+
}
1370+
1371+
#[test]
1372+
fn class_bound_and_default_downleveled_on_312() {
1373+
// the bound rides along into the polyfilled call next to the default
1374+
check_at(
1375+
"class A[T: int = int]: ...\n",
1376+
indoc! {"
1377+
from typing import Generic
1378+
from typing_extensions import TypeVar
1379+
_T = TypeVar(\"_T\", bound=int, default=int)
1380+
class A(Generic[_T]): ...
1381+
"},
1382+
PythonVersion::PY312,
1383+
);
1384+
}
1385+
1386+
#[test]
1387+
fn class_default_unchanged_on_313() {
1388+
let src = "class A[T = int]: ...\n";
1389+
check_at(src, src, PythonVersion::PY313);
1390+
check_at(src, src, PythonVersion::PY314);
1391+
}
1392+
1393+
#[test]
1394+
fn only_defaulted_declarations_downlevel_on_312() {
1395+
// gating is per declaration: the defaulted class polyfills while the
1396+
// plain one keeps native pep 695 syntax
1397+
check_at(
1398+
indoc! {"
1399+
class A[T = int]: ...
1400+
class B[T]: ...
1401+
"},
1402+
indoc! {"
1403+
from typing import Generic
1404+
from typing_extensions import TypeVar
1405+
_T = TypeVar(\"_T\", default=int)
1406+
class A(Generic[_T]): ...
1407+
class B[T]: ...
1408+
"},
1409+
PythonVersion::PY312,
1410+
);
1411+
}
1412+
1413+
#[test]
1414+
fn paramspec_and_typevartuple_defaults_downlevel_on_312() {
1415+
// defaults on any parameter kind make the header 3.13-only syntax, so
1416+
// the whole declaration polyfills. the polyfill itself drops paramspec
1417+
// and typevartuple defaults, matching the pre-3.12 path
1418+
check_at(
1419+
indoc! {"
1420+
class A[**P = [int]]: ...
1421+
class B[*Ts = *tuple[int, str]]: ...
1422+
"},
1423+
indoc! {"
1424+
from typing import TypeVarTuple, Unpack, ParamSpec, Generic
1425+
_P = ParamSpec(\"_P\")
1426+
class A(Generic[_P]): ...
1427+
_Ts = TypeVarTuple(\"_Ts\")
1428+
class B(Generic[*_Ts]): ...
1429+
"},
1430+
PythonVersion::PY312,
1431+
);
1432+
}
1433+
1434+
#[test]
1435+
fn type_alias_default_downleveled_on_312() {
1436+
check_at(
1437+
"type Vector[T = int] = list[T]\n",
1438+
indoc! {"
1439+
from typing_extensions import TypeVar, TypeAliasType
1440+
_T = TypeVar(\"_T\", default=int)
1441+
Vector = TypeAliasType(\"Vector\", list[_T], type_params=(_T,))
1442+
"},
1443+
PythonVersion::PY312,
1444+
);
1445+
}
1446+
1447+
#[test]
1448+
fn type_alias_default_unchanged_on_313() {
1449+
let src = "type Vector[T = int] = list[T]\n";
1450+
check_at(src, src, PythonVersion::PY313);
1451+
check_at(src, src, PythonVersion::PY314);
1452+
}
1453+
13111454
#[test]
13121455
fn variance_covariant_polyfill() {
13131456
check(

‎crates/by_transforms/src/transforms/intersection.rs‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ mod tests {
6969
}
7070

7171
fn check_py312(input: &str, expected: &str) {
72+
check_at(input, expected, PythonVersion::PY312);
73+
}
74+
75+
fn check_at(input: &str, expected: &str, version: PythonVersion) {
7276
let config = Config {
73-
min_version: PythonVersion::PY312,
77+
min_version: version,
7478
..Config::test_default()
7579
};
7680
assert_eq!(
@@ -201,12 +205,29 @@ mod tests {
201205

202206
#[test]
203207
fn intersection_in_typeparam_default() {
204-
check_py312(
208+
// native passthrough of a defaulted list needs 3.13 (pep 696)
209+
check_at(
205210
"def f[T = A & B](x: T) -> T: ...\n",
206211
indoc! {"
207212
from ty_extensions import Intersection
208213
def f[T = Intersection[A, B]](x: T) -> T: ...
209214
"},
215+
PythonVersion::PY313,
216+
);
217+
}
218+
219+
#[test]
220+
fn intersection_in_typeparam_default_downlevels_on_312() {
221+
// on a 3.12 target the defaulted list polyfills, and the intersection
222+
// still lowers inside the `default=` argument
223+
check_py312(
224+
"def f[T = A & B](x: T) -> T: ...\n",
225+
indoc! {"
226+
from ty_extensions import Intersection
227+
from typing_extensions import TypeVar
228+
_T = TypeVar(\"_T\", default=Intersection[A, B])
229+
def f(x: _T) -> _T: ...
230+
"},
210231
);
211232
}
212233

‎crates/by_transforms/src/transforms/not_type.rs‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ mod tests {
1616
}
1717

1818
fn check_py312(input: &str, expected: &str) {
19+
check_at(input, expected, PythonVersion::PY312);
20+
}
21+
22+
fn check_at(input: &str, expected: &str, version: PythonVersion) {
1923
let config = Config {
20-
min_version: PythonVersion::PY312,
24+
min_version: version,
2125
..Config::test_default()
2226
};
2327
assert_eq!(
@@ -122,12 +126,29 @@ mod tests {
122126

123127
#[test]
124128
fn not_in_typeparam_default() {
125-
check_py312(
129+
// native passthrough of a defaulted list needs 3.13 (pep 696)
130+
check_at(
126131
"def f[T = not int](x: T) -> T: ...\n",
127132
indoc! {"
128133
from ty_extensions import Not
129134
def f[T = Not[int]](x: T) -> T: ...
130135
"},
136+
PythonVersion::PY313,
137+
);
138+
}
139+
140+
#[test]
141+
fn not_in_typeparam_default_downlevels_on_312() {
142+
// on a 3.12 target the defaulted list polyfills, and the negation
143+
// still lowers inside the `default=` argument
144+
check_py312(
145+
"def f[T = not int](x: T) -> T: ...\n",
146+
indoc! {"
147+
from ty_extensions import Not
148+
from typing_extensions import TypeVar
149+
_T = TypeVar(\"_T\", default=Not[int])
150+
def f(x: _T) -> _T: ...
151+
"},
131152
);
132153
}
133154

‎crates/by_transforms/src/transforms/reified_generic.rs‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
//! lowered `def` must keep its native `[T]` syntax. that is only available on
3131
//! python 3.12+, so reification is gated on `min_version >= 3.12`; below that
3232
//! the pass reports a hard error rather than emit code that cannot run.
33+
//! pep 696 defaults (`[T = int]`) raise the bar to 3.13 — they are not valid
34+
//! syntax on 3.12, and the erased polyfill can't stand in because it discards
35+
//! the native parameter list reification depends on — so a reified function
36+
//! with a defaulted parameter on a 3.12 target is a hard error too
3337
3438
use ruff_python_ast::visitor::{Visitor, walk_stmt};
3539
use ruff_python_ast::{PythonVersion, Stmt, StmtFunctionDef};
@@ -100,7 +104,10 @@ struct ReifiedGeneric<'src> {
100104
used: bool,
101105
/// a reified function was found but the target is below 3.12
102106
below_312: Vec<String>,
107+
/// a reified function has a pep 696 default but the target is below 3.13
108+
defaulted_below_313: Vec<String>,
103109
supports_native_generics: bool,
110+
supports_param_defaults: bool,
104111
}
105112

106113
impl<'src> ReifiedGeneric<'src> {
@@ -110,7 +117,9 @@ impl<'src> ReifiedGeneric<'src> {
110117
edits: Vec::new(),
111118
used: false,
112119
below_312: Vec::new(),
120+
defaulted_below_313: Vec::new(),
113121
supports_native_generics: min_version >= PythonVersion::PY312,
122+
supports_param_defaults: min_version >= PythonVersion::PY313,
114123
}
115124
}
116125

@@ -122,6 +131,17 @@ impl<'src> ReifiedGeneric<'src> {
122131
self.below_312.push(function.name.id.to_string());
123132
return;
124133
}
134+
// any default in the list makes the native header 3.13-only syntax,
135+
// even on parameters that don't themselves reify
136+
if !self.supports_param_defaults
137+
&& function
138+
.type_params
139+
.as_deref()
140+
.is_some_and(|tp| tp.type_params.iter().any(|p| p.default().is_some()))
141+
{
142+
self.defaulted_below_313.push(function.name.id.to_string());
143+
return;
144+
}
125145
// the decorator goes on its own line directly above the `def`, sharing
126146
// its indentation. the first decorator's range start (or the `def`
127147
// keyword when undecorated) is the insertion point
@@ -175,6 +195,15 @@ impl TypeAwarePass for ReifiedGenericPass<'_> {
175195
));
176196
return;
177197
}
198+
if let Some(name) = inner.defaulted_below_313.first() {
199+
ctx.errors.push(format!(
200+
"reified generic function `{name}` has a type-parameter default, \
201+
which requires python 3.13 or newer: reification keeps the native \
202+
pep 695 parameter list in the generated python, and pep 696 \
203+
defaults are not valid syntax before 3.13"
204+
));
205+
return;
206+
}
178207
if inner.used {
179208
ctx.required_imports
180209
.push("from dataclasses import dataclass".to_owned());
@@ -337,6 +366,51 @@ mod tests {
337366
);
338367
}
339368

369+
#[test]
370+
fn default_on_312_is_an_error() {
371+
// pep 696 defaults are 3.13-only syntax, and a reified function can't
372+
// fall back to the erased polyfill, so 3.12 targets get a hard error
373+
let err = transpile(
374+
indoc! {"
375+
def f[T = int]():
376+
print(T)
377+
"},
378+
&Config {
379+
min_version: PythonVersion::PY312,
380+
..Config::test_default()
381+
},
382+
)
383+
.unwrap_err();
384+
assert!(
385+
err.contains("3.13"),
386+
"expected a 3.13 requirement error, got: {err}"
387+
);
388+
}
389+
390+
#[test]
391+
fn default_on_313_wraps_natively() {
392+
let out = transpile(
393+
indoc! {"
394+
def f[T = int]():
395+
print(T)
396+
f()
397+
"},
398+
&Config {
399+
min_version: PythonVersion::PY313,
400+
..Config::test_default()
401+
},
402+
)
403+
.unwrap();
404+
assert!(
405+
out.contains("@generic # basedpython: reified"),
406+
"should wrap: {out}"
407+
);
408+
assert!(
409+
out.contains("def f[T = int]():"),
410+
"native defaulted params kept: {out}"
411+
);
412+
}
413+
340414
#[test]
341415
fn non_generic_function_untouched() {
342416
unchanged("def f(x):\n return x\n");

0 commit comments

Comments
 (0)