Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions crates/by_transforms/src/reverse_transforms/generics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,23 @@ impl<'src> GenericsReverse<'src> {
&self.source[usize::from(range.start())..usize::from(range.end())]
}

/// `[**P]` → `[P: Parameters]` so paramspec syntax round-trips through
/// the basedpython surface form
/// `[**P]` → `[P: (*: *, **: *)]`.
///
/// In a `.by` file `[**P]` declares a keyword-variadic pack, so a python `ParamSpec` reverses
/// to a type variable bound by the *top parameters* form — an anonymous variadic and an
/// anonymous keyword-variadic, both admitting anything. Every parameter list is a subtype of
/// it, so the bound ranges over all parameter lists, which is exactly a `ParamSpec`
fn rewrite_paramspec(&mut self, params: &[TypeParam]) {
for param in params {
if let TypeParam::ParamSpec(ps) = param {
let name = ps.name.id.as_str();
let default = ps
.default
.as_deref()
.map(|default| format!(" = {}", self.src(default.range())))
.unwrap_or_default();
self.edits.push(Fix::safe_edit(Edit::range_replacement(
format!("{name}: Parameters"),
format!("{name}: (*: *, **: *){default}"),
param.range(),
)));
}
Expand Down Expand Up @@ -124,7 +133,31 @@ mod tests {
check(
"class A[**P]: ...\n",
// empty_class also strips `: ...`
"class A[P: Parameters]\n",
"class A[P: (*: *, **: *)]\n",
);
}

/// a `ParamSpec` default rides along on the reversed bound
#[test]
fn paramspec_default_reversed() {
check(
"class A[**P = ...]: ...\n",
"class A[P: (*: *, **: *) = ...]\n",
);
}

/// the reversed form is what the forward transform reads back as a `ParamSpec`, so the pair
/// round-trips rather than drifting
#[test]
fn paramspec_round_trips_through_the_forward_transform() {
use crate::transpile;
let reversed = reverse_transpile("class A[**P]: ...\n", &Config::test_default())
.expect("reverse failed");
assert_eq!(reversed, "class A[P: (*: *, **: *)]\n");
let forward = transpile(&reversed, &Config::test_default()).expect("forward failed");
assert!(
forward.contains("_P = ParamSpec(\"_P\")") && forward.contains("Generic[_P]"),
"expected a ParamSpec, got:\n{forward}"
);
}

Expand All @@ -136,14 +169,14 @@ mod tests {
return x
"},
indoc! {"
def f[P: Parameters](x: int) -> int:
def f[P: (*: *, **: *)](x: int) -> int:
return x
"},
);
}

#[test]
fn mixed_typevar_and_paramspec_reversed() {
check("class A[T, **P]: ...\n", "class A[T, P: Parameters]\n");
check("class A[T, **P]: ...\n", "class A[T, P: (*: *, **: *)]\n");
}
}
149 changes: 42 additions & 107 deletions crates/by_transforms/src/transforms/generics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ use std::fmt::Write as _;

use ruff_diagnostics::{Edit, Fix};
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
use ruff_python_ast::{
Expr, Stmt, StmtClassDef, StmtFunctionDef, StmtImportFrom, StmtTypeAlias, TypeParam,
};
use ruff_python_ast::{Expr, Stmt, StmtClassDef, StmtFunctionDef, StmtTypeAlias, TypeParam};
use ruff_text_size::{Ranged, TextRange, TextSize};

use crate::config::Config;
Expand Down Expand Up @@ -37,8 +35,8 @@ pub(crate) struct GenericPolyfill<'src> {
emitted_typevar_signatures: std::collections::HashMap<String, String>,
/// counter for fresh-suffix typevar names (`_T_2`, `_T_3`, …)
typevar_suffix_counter: usize,
/// names of classes/functions whose first type parameter is a `Parameters`
/// bound (i.e. `class A[P: Parameters]`). subscript sites for these
/// names of classes/functions whose first type parameter has a top-parameters
/// bound (i.e. `class A[P: (*: *, **: *)]`). subscript sites for these
/// targets get tuple slices rewritten to list form so paramspec
/// substitution at runtime accepts them
parameters_targets: HashSet<String>,
Expand Down Expand Up @@ -261,7 +259,7 @@ impl<'src> GenericPolyfill<'src> {
TypeParam::TypeVar(tv) => {
let name = tv.name.id.as_str();

// `T: Parameters` → emit a ParamSpec rather than a TypeVar
// top-parameters bound → emit a ParamSpec rather than a TypeVar
// so the polyfilled output behaves like `**T` at runtime
if let Some(bound) = &tv.bound
&& is_parameters_bound(bound)
Expand Down Expand Up @@ -418,7 +416,7 @@ impl<'src> GenericPolyfill<'src> {
for param in params {
if let TypeParam::TypeVar(tv) = param {
if let Some(bound) = &tv.bound {
// `T: Parameters` → `**T` (PEP 695 paramspec syntax)
// top-parameters bound → `**T` (PEP 695 paramspec syntax)
if is_parameters_bound(bound) {
let name = tv.name.id.as_str();
self.edits.push(Fix::safe_edit(Edit::range_replacement(
Expand Down Expand Up @@ -744,61 +742,6 @@ impl<'src> GenericPolyfill<'src> {
}

impl GenericPolyfill<'_> {
/// Strips `Parameters` from a `from typing import …` line. Parameters is
/// a basedpython surface form; the import has no runtime equivalent so
/// it must not appear in the lowered Python output.
fn strip_parameters_import(&mut self, node: &StmtImportFrom) {
if node.level > 0 {
return;
}
let Some(module) = &node.module else {
return;
};
if module.id.as_str() != "typing" {
return;
}
let mut keep: Vec<String> = Vec::new();
let mut found = false;
for alias in &node.names {
let name = alias.name.id.as_str();
if name == "Parameters" && alias.asname.is_none() {
found = true;
continue;
}
let formatted = match &alias.asname {
Some(asname) => format!("{name} as {}", asname.id.as_str()),
None => name.to_owned(),
};
keep.push(formatted);
}
if !found {
return;
}
let replacement = if keep.is_empty() {
// drop the entire line including its trailing newline
let line_end = self.line_end_of(node.range().end());
self.edits
.push(Fix::safe_edit(Edit::range_deletion(TextRange::new(
node.range().start(),
line_end,
))));
return;
} else {
format!("from typing import {}", keep.join(", "))
};
self.edits.push(Fix::safe_edit(Edit::range_replacement(
replacement,
node.range(),
)));
}

fn line_end_of(&self, pos: TextSize) -> TextSize {
let offset = usize::from(pos);
let rest = &self.source[offset..];
let extra = rest.find('\n').map_or(rest.len(), |i| i + 1);
TextSize::from(u32::try_from(offset + extra).expect("offset fits u32"))
}

/// Rewrites a tuple slice of a parameters-typed subscript to a list.
/// `A[(int, str)]` → `A[[int, str]]` so the runtime `ParamSpec` accepts
/// the substitution. Parameters spec syntax (`(int, str, /, name: T)`)
Expand Down Expand Up @@ -892,7 +835,6 @@ impl<'ast> Visitor<'ast> for GenericPolyfill<'_> {
self.process_type_alias(alias);
return; // don't recurse into the alias value
}
Stmt::ImportFrom(imp) => self.strip_parameters_import(imp),
_ => {}
}
walk_stmt(self, stmt);
Expand All @@ -917,15 +859,10 @@ fn has_parameters_bound(params: &[TypeParam]) -> bool {
})
}

/// basedpython spells a `ParamSpec` as a type variable bound by the top parameters form
/// `(*: *, **: *)` — the parameter list every other parameter list is a subtype of
fn is_parameters_bound(bound: &Expr) -> bool {
match bound {
Expr::Name(n) => n.id.as_str() == "Parameters",
Expr::Attribute(a) => {
a.attr.id.as_str() == "Parameters"
&& matches!(a.value.as_ref(), Expr::Name(m) if m.id.as_str() == "typing")
}
_ => false,
}
ruff_python_ast::helpers::is_top_parameters_form(bound)
}

fn rename_in_expr(expr: &Expr, renames: &HashMap<String, String>, edits: &mut Vec<Fix>) {
Expand Down Expand Up @@ -964,6 +901,14 @@ fn rename_in_expr(expr: &Expr, renames: &HashMap<String, String>, edits: &mut Ve
}
Expr::UnaryOp(u) => rename_in_expr(&u.operand, renames, edits),
Expr::Starred(s) => rename_in_expr(&s.value, renames, edits),
// an arrow callable `(**P) -> None` lowers to `Callable[P, None]` via a template edit
// that passes its operand source through, so a rename on the inner name still lands
Expr::CallableType(c) => {
c.args
.iter()
.for_each(|a| rename_in_expr(a, renames, edits));
rename_in_expr(&c.returns, renames, edits);
}
_ => {}
}
}
Expand All @@ -988,6 +933,17 @@ fn rename_in_stmt(stmt: &Stmt, renames: &HashMap<String, String>, edits: &mut Ve
rename_in_expr(ann, renames, edits);
}
}
for variadic in [
f.parameters.vararg.as_deref(),
f.parameters.kwarg.as_deref(),
]
.into_iter()
.flatten()
{
if let Some(ann) = &variadic.annotation {
rename_in_expr(ann, renames, edits);
}
}
if let Some(ret) = &f.returns {
rename_in_expr(ret, renames, edits);
}
Expand Down Expand Up @@ -1858,13 +1814,10 @@ mod tests {
fn parameters_bound_polyfill() {
check(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
"},
indoc! {"
from typing import ParamSpec, Generic

_P = ParamSpec(\"_P\")
class A(Generic[_P]): ...
"},
Expand All @@ -1875,12 +1828,9 @@ mod tests {
fn parameters_bound_native_312() {
check_at(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
"},
indoc! {"

class A[**P]: ...
"},
PythonVersion::PY312,
Expand All @@ -1893,14 +1843,11 @@ mod tests {
// ParamSpec receives the right shape at runtime
check(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, str)]
"},
indoc! {"
from typing import ParamSpec, Generic

_P = ParamSpec(\"_P\")
class A(Generic[_P]): ...
A[[int, str]]
Expand All @@ -1915,14 +1862,11 @@ mod tests {
// carries positional types
check(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, str, /, name: str)]
"},
indoc! {"
from typing import Any, ParamSpec, Generic

_P = ParamSpec(\"_P\")
class A(Generic[_P]): ...
A[[int, str, Any]]
Expand All @@ -1934,14 +1878,11 @@ mod tests {
fn parameters_subscript_with_markers_native_312() {
check_at(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, str, /, name: str)]
"},
indoc! {"
from typing import Any

class A[**P]: ...
A[[int, str, Any]]
"},
Expand All @@ -1953,8 +1894,7 @@ mod tests {
fn parameters_subscript_named_only() {
check_at(
indoc! {"
from typing import Parameters
class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(/, x: int)]
"},
indoc! {"
Expand All @@ -1972,8 +1912,7 @@ mod tests {
// runtime ParamSpec list has no kwargs slot
check_at(
indoc! {"
from typing import Parameters
class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, **: str)]
"},
indoc! {"
Expand All @@ -1990,8 +1929,7 @@ mod tests {
// to `Any` in paramspec list since runtime form has no variadic slot
check_at(
indoc! {"
from typing import Parameters
class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, *: str)]
"},
indoc! {"
Expand All @@ -2007,13 +1945,10 @@ mod tests {
fn parameters_subscript_native_312() {
check_at(
indoc! {"
from typing import Parameters

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
A[(int, str)]
"},
indoc! {"

class A[**P]: ...
A[[int, str]]
"},
Expand All @@ -2025,8 +1960,7 @@ mod tests {
fn parameters_function_polyfill() {
check(
indoc! {"
from typing import Parameters
def f[P: Parameters](): ...
def f[P: (*: *, **: *)](): ...
"},
indoc! {"
from typing import ParamSpec
Expand All @@ -2036,14 +1970,15 @@ mod tests {
);
}

/// the top-parameters bound is structural, so it pulls in no import of its own and leaves
/// the module's existing `typing` imports alone
#[test]
fn parameters_import_kept_when_other_names_present() {
// only the `Parameters` name is stripped; siblings stay
fn parameters_bound_needs_no_import() {
check(
indoc! {"
from typing import Parameters, TypeVar
from typing import TypeVar

class A[P: Parameters]: ...
class A[P: (*: *, **: *)]: ...
"},
indoc! {"
from typing import ParamSpec, Generic
Expand Down
Loading
Loading