Skip to content

Commit 48dbb32

Browse files
committed
map (...) -> R to Callable[..., R] and strip stray stub bodies
1 parent e74c31c commit 48dbb32

165 files changed

Lines changed: 1371 additions & 1088 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/by_transforms/src/lib.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -487,12 +487,16 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
487487
let mut super_kw_rev = reverse_transforms::super_keyword::SuperKeywordReverse::new(src);
488488
let mut anon_named_tuple_rev =
489489
reverse_transforms::anon_named_tuple::AnonNamedTupleReverse::new(src, module.suite());
490-
let mut empty_decls = reverse_transforms::empty_declarations::EmptyDeclarations::new();
490+
let mut empty_decls =
491+
reverse_transforms::empty_declarations::EmptyDeclarations::new(config.is_stub);
491492
let mut literal_types = reverse_transforms::literal_types::LiteralReverse::new(src, &model);
492493
let mut subscript = reverse_transforms::subscript::SubscriptReverse::new(src, &model);
493494
let mut indent_string = reverse_transforms::dedent_string::IndentString::new(src);
494495
let mut constraints = reverse_transforms::constraints::ConstraintsReverse::new();
495-
let mut callable = reverse_transforms::callable::CallableReverse::new(src, &model);
496+
let mut callable = {
497+
let c = reverse_transforms::callable::CallableReverse::new(src, &model);
498+
if config.is_stub { c.stub() } else { c }
499+
};
496500
let mut intersection = reverse_transforms::intersection::IntersectionReverse::new(src, &model);
497501
let mut not_rev = reverse_transforms::not_type::NotTypeReverse::new(src, &model);
498502
let mut type_is_rev = reverse_transforms::type_is::TypeIsReverse::new(src, &model);
@@ -525,21 +529,24 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
525529
auto_quote_rev.visit_stmt(stmt);
526530
compat_rev.visit_stmt(stmt);
527531
none_chain_rev.visit_stmt(stmt);
532+
// `callable` rewrites callable annotations to the arrow form. it runs
533+
// for stubs too, but in a restricted "stub" mode (set above) that only
534+
// touches the gradual `Callable[..., R]` form — the `Callable[[A, B],
535+
// R]` list form is left intact, since ty's native basedpython parser
536+
// can't carry `Unpack[Ts]`/`*Ts` through the arrow and stubs would
537+
// lose generic callable info
538+
callable.visit_stmt(stmt);
528539
// skip transforms that change runtime/display semantics when
529540
// rewriting stubs:
530541
// - `literal_types` strips `Literal[...]` to bare literals, but a
531542
// bare `1 | 2` in a `TypeAlias = ...` RHS evaluates at runtime
532543
// as integer OR (= `3`) rather than `Literal[1, 2]`
533-
// - `callable` rewrites `Callable[[A, B], R]` to `(A, B) -> R`;
534-
// ty's native basedpython parser doesn't handle `Unpack[Ts]` and
535-
// `*Ts` inside the arrow form, so stubs lose generic callable info
536544
// - `typing_redirect` rewrites `typing_extensions` imports, but
537545
// stubs use them deliberately for version-aware re-exports
538546
// - `generics` turns `X: TypeAlias = T` into PEP 695 `type X = T`,
539547
// which resolves lazily and changes alias display in diagnostics
540548
if !config.is_stub {
541549
literal_types.visit_stmt(stmt);
542-
callable.visit_stmt(stmt);
543550
typing_redirect_rev.visit_stmt(stmt);
544551
generics_rev.visit_stmt(stmt);
545552
}

crates/by_transforms/src/reverse_transforms/callable.rs

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77
88
use ruff_diagnostics::{Edit, Fix};
99
use ruff_python_ast::visitor::Visitor;
10-
use ruff_python_ast::{Expr, Stmt};
10+
use ruff_python_ast::{Expr, ExprSubscript, Stmt};
1111
use ruff_text_size::{Ranged, TextRange};
1212

1313
use crate::type_info::TypeInfo;
1414

1515
pub(crate) struct CallableReverse<'src> {
1616
source: &'src str,
1717
types: &'src dyn TypeInfo,
18+
/// in stub mode, the `Callable[[A, B], R]` list form is left intact —
19+
/// ty's basedpython parser can't carry `Unpack[Ts]`/`*Ts` through the
20+
/// arrow form, so stubs would lose generic callable info. the gradual
21+
/// `Callable[..., R]` form has no parameter list to lose and is always
22+
/// rewritten to `(...) -> R`
23+
stub: bool,
1824
pub(crate) edits: Vec<Fix>,
1925
}
2026

@@ -23,10 +29,16 @@ impl<'src> CallableReverse<'src> {
2329
Self {
2430
source,
2531
types,
32+
stub: false,
2633
edits: Vec::new(),
2734
}
2835
}
2936

37+
pub(crate) fn stub(mut self) -> Self {
38+
self.stub = true;
39+
self
40+
}
41+
3042
fn src(&self, range: TextRange) -> &str {
3143
&self.source[usize::from(range.start())..usize::from(range.end())]
3244
}
@@ -51,9 +63,27 @@ impl<'src> CallableReverse<'src> {
5163
if t.parenthesized || t.elts.len() != 2 {
5264
return None;
5365
}
66+
let ret = &t.elts[1];
67+
// `Callable[..., R]` — "any arguments" — reverses to `(...) -> R`.
68+
// safe in stub mode: there is no parameter list to lose
69+
if matches!(&t.elts[0], Expr::EllipsisLiteral(_)) {
70+
let ret_str = self
71+
.rewrite(ret)
72+
.unwrap_or_else(|| self.src(ret.range()).to_owned());
73+
return Some(format!("(...) -> {ret_str}"));
74+
}
75+
// list form: leave the `Callable[...]` wrapper intact in stub
76+
// mode but still recurse so any nested `Callable[..., R]` is
77+
// converted
78+
if self.stub {
79+
return self.rewrite_subscript_children(s);
80+
}
5481
let Expr::List(args_list) = &t.elts[0] else {
5582
return None;
5683
};
84+
let ret_str = self
85+
.rewrite(ret)
86+
.unwrap_or_else(|| self.src(ret.range()).to_owned());
5787
let args_str = args_list
5888
.elts
5989
.iter()
@@ -63,10 +93,6 @@ impl<'src> CallableReverse<'src> {
6393
})
6494
.collect::<Vec<_>>()
6595
.join(", ");
66-
let ret = &t.elts[1];
67-
let ret_str = self
68-
.rewrite(ret)
69-
.unwrap_or_else(|| self.src(ret.range()).to_owned());
7096
Some(format!("({args_str}) -> {ret_str}"))
7197
}
7298

@@ -82,31 +108,53 @@ impl<'src> CallableReverse<'src> {
82108
}
83109
}
84110

85-
Expr::Subscript(s) => {
86-
let slice_rewrite = match s.slice.as_ref() {
87-
Expr::Tuple(t) if !t.parenthesized => {
88-
let rewrites: Vec<Option<String>> =
89-
t.elts.iter().map(|e| self.rewrite(e)).collect();
90-
if rewrites.iter().any(Option::is_some) {
91-
let parts: Vec<String> = rewrites
92-
.into_iter()
93-
.zip(t.elts.iter())
94-
.map(|(r, e)| r.unwrap_or_else(|| self.src(e.range()).to_owned()))
95-
.collect();
96-
Some(parts.join(", "))
97-
} else {
98-
None
99-
}
100-
}
101-
slice => self.rewrite(slice),
102-
};
103-
slice_rewrite.map(|s_text| format!("{}[{s_text}]", self.src(s.value.range())))
111+
Expr::Subscript(s) => self.rewrite_subscript_children(s),
112+
113+
// descend into list literals (e.g. a `Callable[[A, B], R]` left
114+
// intact in stub mode) so nested callable forms are still rewritten
115+
Expr::List(l) => {
116+
let rewrites: Vec<Option<String>> =
117+
l.elts.iter().map(|e| self.rewrite(e)).collect();
118+
if rewrites.iter().any(Option::is_some) {
119+
let parts: Vec<String> = rewrites
120+
.into_iter()
121+
.zip(l.elts.iter())
122+
.map(|(r, e)| r.unwrap_or_else(|| self.src(e.range()).to_owned()))
123+
.collect();
124+
Some(format!("[{}]", parts.join(", ")))
125+
} else {
126+
None
127+
}
104128
}
105129

106130
_ => None,
107131
}
108132
}
109133

134+
/// recurse into a subscript's slice, rewriting any nested callable forms
135+
/// while keeping the `value[...]` wrapper. returns `None` if nothing in
136+
/// the slice changed
137+
fn rewrite_subscript_children(&mut self, s: &ExprSubscript) -> Option<String> {
138+
let slice_rewrite = match s.slice.as_ref() {
139+
Expr::Tuple(t) if !t.parenthesized => {
140+
let rewrites: Vec<Option<String>> =
141+
t.elts.iter().map(|e| self.rewrite(e)).collect();
142+
if rewrites.iter().any(Option::is_some) {
143+
let parts: Vec<String> = rewrites
144+
.into_iter()
145+
.zip(t.elts.iter())
146+
.map(|(r, e)| r.unwrap_or_else(|| self.src(e.range()).to_owned()))
147+
.collect();
148+
Some(parts.join(", "))
149+
} else {
150+
None
151+
}
152+
}
153+
slice => self.rewrite(slice),
154+
};
155+
slice_rewrite.map(|s_text| format!("{}[{s_text}]", self.src(s.value.range())))
156+
}
157+
110158
fn visit_annotation(&mut self, ann: &Expr) {
111159
if let Some(rewrite) = self.rewrite(ann) {
112160
self.edits.push(Fix::safe_edit(Edit::range_replacement(
@@ -137,6 +185,33 @@ mod tests {
137185
);
138186
}
139187

188+
fn check_stub(input: &str, expected: &str) {
189+
let config = Config {
190+
is_stub: true,
191+
..Config::test_default()
192+
};
193+
assert_eq!(reverse_transpile(input, &config).unwrap(), expected);
194+
}
195+
196+
#[test]
197+
fn stub_keeps_list_form_but_rewrites_ellipsis() {
198+
// in stub mode the `Callable[[A], R]` list form is preserved (can't
199+
// carry `Unpack[Ts]`/`*Ts` through the arrow), but the gradual
200+
// `Callable[..., R]` form is still rewritten to `(...) -> R`
201+
check_stub(
202+
"from typing import Callable\na: Callable[..., int]\nb: Callable[[int], str]\n",
203+
"from typing import Callable\na: (...) -> int\nb: Callable[[int], str]\n",
204+
);
205+
}
206+
207+
#[test]
208+
fn stub_rewrites_nested_ellipsis_inside_list_form() {
209+
check_stub(
210+
"from typing import Callable\na: Callable[[Callable[..., int]], str]\n",
211+
"from typing import Callable\na: Callable[[(...) -> int], str]\n",
212+
);
213+
}
214+
140215
#[test]
141216
fn simple_callable() {
142217
check(
@@ -161,6 +236,22 @@ mod tests {
161236
);
162237
}
163238

239+
#[test]
240+
fn ellipsis_args() {
241+
check(
242+
"from typing import Callable\na: Callable[..., int]\n",
243+
"from typing import Callable\na: (...) -> int\n",
244+
);
245+
}
246+
247+
#[test]
248+
fn ellipsis_args_nested_return() {
249+
check(
250+
"from typing import Callable\na: Callable[..., Callable[[int], str]]\n",
251+
"from typing import Callable\na: (...) -> (int) -> str\n",
252+
);
253+
}
254+
164255
#[test]
165256
fn callable_in_union() {
166257
check(

crates/by_transforms/src/reverse_transforms/empty_declarations.rs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,29 @@ use ruff_python_ast::{Expr, Stmt, StmtClassDef, StmtFunctionDef};
2020
use ruff_text_size::{Ranged, TextRange, TextSize};
2121

2222
pub(crate) struct EmptyDeclarations {
23+
/// when reversing a non-stub `.py`, an abstract method keeps its `: ...`
24+
/// body: the forward pass maps a bodyless `abstract def` to `: raise
25+
/// NotImplementedError`, so stripping the body would not round-trip. in a
26+
/// stub the body is dropped — bodyless is the stub idiom and the forward
27+
/// pass re-emits `: ...` there
28+
is_stub: bool,
2329
pub(crate) edits: Vec<Fix>,
2430
}
2531

2632
impl EmptyDeclarations {
27-
pub(crate) fn new() -> Self {
28-
Self { edits: Vec::new() }
33+
pub(crate) fn new(is_stub: bool) -> Self {
34+
Self {
35+
is_stub,
36+
edits: Vec::new(),
37+
}
38+
}
39+
40+
fn is_abstract(func: &StmtFunctionDef) -> bool {
41+
func.decorator_list.iter().any(|d| match &d.expression {
42+
Expr::Name(n) => n.id.as_str() == "abstractmethod",
43+
Expr::Attribute(a) => a.attr.id.as_str() == "abstractmethod",
44+
_ => false,
45+
})
2946
}
3047

3148
fn is_ellipsis_body(body: &[Stmt]) -> bool {
@@ -58,9 +75,22 @@ impl EmptyDeclarations {
5875
}
5976

6077
fn process_function(&mut self, func: &StmtFunctionDef) {
61-
// Decorated functions belong to specialized reverse passes (e.g.
62-
// overload) that know how to handle the whole group atomically.
63-
if !func.decorator_list.is_empty() {
78+
// `@overload`-decorated functions belong to the overload reverse pass,
79+
// which strips the decorator and the `: ...` body together. other
80+
// decorators (`@property`, `@deprecated`, modifier-backed ones like
81+
// `@abstractmethod`/`@final`) are fine to strip the body from — the
82+
// decorator/modifier survives in front of the now-bodyless def
83+
if func
84+
.decorator_list
85+
.iter()
86+
.any(|d| matches!(&d.expression, Expr::Name(n) if n.id.as_str() == "overload"))
87+
{
88+
return;
89+
}
90+
// outside a stub, an abstract method's `: ...` body must survive: the
91+
// forward pass turns a bodyless `abstract def` into `: raise
92+
// NotImplementedError`, so dropping it here would not round-trip
93+
if !self.is_stub && Self::is_abstract(func) {
6494
return;
6595
}
6696
if !Self::is_ellipsis_body(&func.body) {
@@ -251,6 +281,71 @@ mod tests {
251281
);
252282
}
253283

284+
#[test]
285+
fn property_decorated_function_stripped() {
286+
// non-`@overload` decorators don't defer to the overload pass; the
287+
// `: ...` body is stripped and the decorator survives in front
288+
check(
289+
indoc! {"
290+
class A:
291+
@property
292+
def x(self) -> int: ...
293+
"},
294+
indoc! {"
295+
class A:
296+
@property
297+
def x(self) -> int
298+
"},
299+
);
300+
}
301+
302+
#[test]
303+
fn abstract_function_keeps_body_in_non_stub() {
304+
// non-stub: `@abstractmethod` reverses to `abstract` but the `: ...`
305+
// body is kept — a bodyless `abstract def` forward-maps to `: raise
306+
// NotImplementedError`, so stripping would not round-trip
307+
check(
308+
indoc! {"
309+
from abc import abstractmethod
310+
class A:
311+
@abstractmethod
312+
def f(self) -> None: ...
313+
"},
314+
indoc! {"
315+
from abc import abstractmethod
316+
class A:
317+
abstract def f(self) -> None: ...
318+
"},
319+
);
320+
}
321+
322+
#[test]
323+
fn abstract_function_stripped_in_stub() {
324+
// stub: bodyless is the idiom and the forward pass re-emits `: ...`
325+
// for an abstract method in a stub, so the body is dropped here
326+
let config = Config {
327+
is_stub: true,
328+
..Config::test_default()
329+
};
330+
assert_eq!(
331+
reverse_transpile(
332+
indoc! {"
333+
from abc import abstractmethod
334+
class A:
335+
@abstractmethod
336+
def f(self) -> None: ...
337+
"},
338+
&config,
339+
)
340+
.unwrap(),
341+
indoc! {"
342+
from abc import abstractmethod
343+
class A:
344+
abstract def f(self) -> None
345+
"},
346+
);
347+
}
348+
254349
#[test]
255350
fn decorated_function_left_to_overload_pass() {
256351
// @overload-decorated stubs are handled by the overload reverse pass;

0 commit comments

Comments
 (0)