Skip to content

Commit 5811072

Browse files
committed
generic ? is the wrapped optional
1 parent 0d99db9 commit 5811072

10 files changed

Lines changed: 320 additions & 48 deletions

File tree

crates/by_transforms/src/transforms/coalesce.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,22 @@ use crate::type_info::TypeInfo;
1212
/// inside the rewrite instead of being clobbered by first-wins overlap dedup
1313
pub(crate) struct NoneCoalesce<'src> {
1414
source: &'src str,
15+
types: &'src dyn TypeInfo,
1516
pub(crate) edits: Vec<(TextRange, Vec<Fragment>)>,
1617
}
1718

1819
impl<'src> NoneCoalesce<'src> {
19-
pub(crate) fn new(source: &'src str) -> Self {
20+
pub(crate) fn new(source: &'src str, types: &'src dyn TypeInfo) -> Self {
2021
Self {
2122
source,
23+
types,
2224
edits: Vec::new(),
2325
}
2426
}
2527
}
2628

27-
fn expand_none_chain(expr: &Expr, source: &str) -> Option<String> {
28-
let (form, guards) = super::none_chain::expand_chain(expr, source)?;
29+
fn expand_none_chain(expr: &Expr, source: &str, types: &dyn TypeInfo) -> Option<String> {
30+
let (form, guards) = super::none_chain::expand_chain(expr, source, types)?;
2931
Some(super::none_chain::build_expansion(&guards, &form, "_t"))
3032
}
3133

@@ -68,10 +70,17 @@ impl NoneCoalesce<'_> {
6870
return self.operand_value(&b.left);
6971
}
7072
let rhs = self.operand_value(&b.right);
71-
match expand_none_chain(&b.left, self.source) {
73+
// a wrapped optional (`int??`, a generic `T?`) keeps its present value
74+
// inside the runtime wrapper — the present branch unwraps with `.value`
75+
let unwrap = if self.types.wrapped_optional(&b.left) {
76+
".value"
77+
} else {
78+
""
79+
};
80+
match expand_none_chain(&b.left, self.source, self.types) {
7281
Some(expanded) => {
7382
let mut t = vec![
74-
Fragment::Lit("_t if (_t := ".to_owned()),
83+
Fragment::Lit(format!("_t{unwrap} if (_t := ")),
7584
Fragment::Lit(expanded),
7685
Fragment::Lit(") is not None else ".to_owned()),
7786
];
@@ -81,7 +90,7 @@ impl NoneCoalesce<'_> {
8190
None if is_trivially_pure(&b.left) => {
8291
let mut t = vec![
8392
Fragment::Src(b.left.range()),
84-
Fragment::Lit(" if ".to_owned()),
93+
Fragment::Lit(format!("{unwrap} if ")),
8594
Fragment::Src(b.left.range()),
8695
Fragment::Lit(" is not None else ".to_owned()),
8796
];
@@ -92,7 +101,7 @@ impl NoneCoalesce<'_> {
92101
// a chained (left-associative) `??` puts another coalesce on the
93102
// left; recurse through `operand_value` so it is lowered rather
94103
// than copied verbatim. hoist to walrus so the LHS runs once
95-
let mut t = vec![Fragment::Lit("_t if (_t := ".to_owned())];
104+
let mut t = vec![Fragment::Lit(format!("_t{unwrap} if (_t := "))];
96105
t.extend(self.operand_value(&b.left));
97106
t.push(Fragment::Lit(") is not None else ".to_owned()));
98107
t.extend(rhs);
@@ -110,7 +119,7 @@ impl NoneCoalesce<'_> {
110119
{
111120
return self.expand_coalesce(expr);
112121
}
113-
if let Some(expanded) = expand_none_chain(expr, self.source) {
122+
if let Some(expanded) = expand_none_chain(expr, self.source, self.types) {
114123
return vec![Fragment::Lit(expanded)];
115124
}
116125
vec![Fragment::Src(expr.range())]
@@ -161,8 +170,8 @@ impl<'src> NoneCoalescePass<'src> {
161170
}
162171

163172
impl TypeAwarePass for NoneCoalescePass<'_> {
164-
fn run(&self, stmts: &[Stmt], _types: &dyn TypeInfo, ctx: &mut PassContext) {
165-
let mut inner = NoneCoalesce::new(self.source);
173+
fn run(&self, stmts: &[Stmt], types: &dyn TypeInfo, ctx: &mut PassContext) {
174+
let mut inner = NoneCoalesce::new(self.source, types);
166175
for stmt in stmts {
167176
inner.visit_stmt(stmt);
168177
}
@@ -254,6 +263,21 @@ mod tests {
254263
);
255264
}
256265

266+
#[test]
267+
fn wrapped_lhs_unwraps_present_value() {
268+
// a wrapped optional's present value lives inside the runtime wrapper —
269+
// the present branch reads `.value`
270+
let out = transpile(
271+
"def g() -> int??:\n return Some(5)\nx = g() ?? -1\n",
272+
&Config::test_default(),
273+
)
274+
.unwrap();
275+
assert!(
276+
out.contains("x = _t.value if (_t := g()) is not None else -1\n"),
277+
"got: {out}"
278+
);
279+
}
280+
257281
#[test]
258282
fn python_unchanged() {
259283
let src = "x = a if a is not None else b\n";

crates/by_transforms/src/transforms/none_chain.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,31 @@ fn pick_temp_var(types: &dyn TypeInfo, anchor: &Expr) -> &'static str {
4040
/// walks an attribute-access chain and returns `Some((python_form, guards))` when
4141
/// any `?.` is present, where `python_form` has all `?.` replaced by `.` and
4242
/// `guards` is the ordered list of accumulated sub-expressions that must be
43-
/// non-None before each subsequent optional access is safe
44-
pub(super) fn expand_chain(expr: &Expr, source: &str) -> Option<(String, Vec<String>)> {
43+
/// non-None before each subsequent optional access is safe.
44+
///
45+
/// a `?.` whose base is a *wrapped* optional (`int??`, a generic `T?`)
46+
/// reaches its present value through the runtime wrapper: the guard still
47+
/// tests the wrapper against `None`, but the access reads `.value` first
48+
pub(super) fn expand_chain(
49+
expr: &Expr,
50+
source: &str,
51+
types: &dyn TypeInfo,
52+
) -> Option<(String, Vec<String>)> {
4553
let Expr::Attribute(attr) = expr else {
4654
return None;
4755
};
4856
let field = attr.attr.as_str();
49-
match expand_chain(&attr.value, source) {
57+
let unwrap = if attr.optional && types.wrapped_optional(&attr.value) {
58+
".value"
59+
} else {
60+
""
61+
};
62+
match expand_chain(&attr.value, source, types) {
5063
Some((v_form, mut guards)) => {
5164
if attr.optional {
5265
guards.push(v_form.clone());
5366
}
54-
Some((format!("{v_form}.{field}"), guards))
67+
Some((format!("{v_form}{unwrap}.{field}"), guards))
5568
}
5669
None => {
5770
if !attr.optional {
@@ -60,7 +73,7 @@ pub(super) fn expand_chain(expr: &Expr, source: &str) -> Option<(String, Vec<Str
6073
let start = usize::from(attr.value.range().start());
6174
let end = usize::from(attr.value.range().end());
6275
let v_form = source[start..end].to_owned();
63-
Some((format!("{v_form}.{field}"), vec![v_form]))
76+
Some((format!("{v_form}{unwrap}.{field}"), vec![v_form]))
6477
}
6578
}
6679
}
@@ -132,7 +145,7 @@ impl<'ast> Visitor<'ast> for NoneChain<'_> {
132145

133146
fn visit_expr(&mut self, expr: &'ast Expr) {
134147
if let Expr::Attribute(_) = expr {
135-
if let Some((form, guards)) = expand_chain(expr, self.source) {
148+
if let Some((form, guards)) = expand_chain(expr, self.source, self.types) {
136149
let temp = pick_temp_var(self.types, expr);
137150
self.edits.push(Fix::safe_edit(Edit::range_replacement(
138151
build_expansion(&guards, &form, temp),
@@ -157,6 +170,22 @@ mod tests {
157170
);
158171
}
159172

173+
#[test]
174+
fn wrapped_base_unwraps_value() {
175+
// `?.` on a wrapped optional reads the present value through the
176+
// runtime wrapper: the guard tests the wrapper, the access goes
177+
// through `.value`
178+
let out = transpile(
179+
"def g() -> int??:\n return Some(5)\nw = g()\nx = w?.bit_length\n",
180+
&Config::test_default(),
181+
)
182+
.unwrap();
183+
assert!(
184+
out.contains("x = None if w is None else w.value.bit_length\n"),
185+
"got: {out}"
186+
);
187+
}
188+
160189
#[test]
161190
fn basic_chain() {
162191
check("x = a?.b\n", "x = None if a is None else a.b\n");

crates/by_transforms/src/transforms/optional_type.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ struct OptionalLower<'src> {
3636
/// set when any lowered optional produced a runtime `Optional[...]` wrapper
3737
needs_runtime: bool,
3838
source: &'src str,
39+
/// stack of in-scope PEP 695 type-parameter names. `?` over a bare type
40+
/// variable lowers to the *wrapped* form (`Optional[T | None]`) — a plain
41+
/// union would flatten when `T` binds to an optional (mirrors ty's typing
42+
/// of a generic `T?`)
43+
typevar_scopes: Vec<Vec<String>>,
3944
}
4045

4146
impl<'src> OptionalLower<'src> {
@@ -44,13 +49,39 @@ impl<'src> OptionalLower<'src> {
4449
edits: Vec::new(),
4550
needs_runtime: false,
4651
source,
52+
typevar_scopes: Vec::new(),
4753
}
4854
}
55+
56+
fn in_scope_typevar(&self, name: &str) -> bool {
57+
self.typevar_scopes
58+
.iter()
59+
.any(|scope| scope.iter().any(|n| n == name))
60+
}
4961
}
5062

5163
impl<'ast> Visitor<'ast> for OptionalLower<'_> {
5264
fn visit_stmt(&mut self, stmt: &'ast Stmt) {
65+
let type_params = match stmt {
66+
Stmt::FunctionDef(f) => f.type_params.as_deref(),
67+
Stmt::ClassDef(c) => c.type_params.as_deref(),
68+
_ => None,
69+
};
70+
let pushed = if let Some(tp) = type_params {
71+
self.typevar_scopes.push(
72+
tp.type_params
73+
.iter()
74+
.map(|p| p.name().id.as_str().to_owned())
75+
.collect(),
76+
);
77+
true
78+
} else {
79+
false
80+
};
5381
walk_stmt(self, stmt);
82+
if pushed {
83+
self.typevar_scopes.pop();
84+
}
5485
}
5586

5687
fn visit_expr(&mut self, expr: &'ast Expr) {
@@ -82,16 +113,21 @@ impl<'ast> Visitor<'ast> for OptionalLower<'_> {
82113
let node = unary.range;
83114
let tail = &self.source[usize::from(inner.range().end())..usize::from(node.end())];
84115
let close_parens: String = tail.chars().filter(|c| *c == ')').collect();
85-
if depth >= 2 {
116+
// a bare in-scope type variable gets one extra wrapper layer: `T?` is
117+
// `Optional[T | None]`, matching ty's wrapped typing of a generic `?`
118+
let generic_operand =
119+
matches!(inner, Expr::Name(n) if self.in_scope_typevar(n.id.as_str()));
120+
let wrap_layers = (depth - 1) as usize + usize::from(generic_operand);
121+
if wrap_layers >= 1 {
86122
self.needs_runtime = true;
87123
self.edits.push((
88124
TextRange::empty(node.start()),
89-
"Optional[".repeat((depth - 1) as usize),
125+
"Optional[".repeat(wrap_layers),
90126
));
91127
}
92128
let mut replacement = close_parens;
93129
replacement.push_str(" | None");
94-
for _ in 1..depth {
130+
for _ in 0..wrap_layers {
95131
replacement.push(']');
96132
}
97133
self.edits
@@ -210,6 +246,32 @@ class Optional:
210246
);
211247
}
212248

249+
#[test]
250+
fn generic_typevar_optional_wraps() {
251+
// `?` over a bare in-scope type variable is the wrapped form — a plain
252+
// union would flatten when `T` binds to an optional. (the 3.10 generics
253+
// polyfill's `T` → `_T` rename composes inside the wrapper)
254+
check(
255+
indoc! {"
256+
def f[T](t: T) -> T?:
257+
return Some(t)
258+
"},
259+
&format!(
260+
"from typing import TypeVar\n{RUNTIME}_T = TypeVar(\"_T\")\ndef f(t: _T) -> Optional[_T | None]:\n return Optional(t)\n"
261+
),
262+
);
263+
}
264+
265+
#[test]
266+
fn non_typevar_name_optional_stays_union() {
267+
// a name that is not an in-scope type parameter lowers to the plain
268+
// union as before
269+
check(
270+
"def f(x: int?) -> None: ...\n",
271+
"def f(x: int | None) -> None: ...\n",
272+
);
273+
}
274+
213275
#[test]
214276
fn optional_of_negation_composes() {
215277
// the operand source is preserved, so the `not_type` transform still

crates/by_transforms/src/transforms/propagate.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ use crate::type_info::{AbsentTest, TypeInfo};
3434
/// result-like `T | E` tests `isinstance(_, BaseException)`.
3535
fn absent_condition(test: AbsentTest, target: &str) -> String {
3636
match test {
37-
AbsentTest::IsNone => format!("{target} is None"),
38-
AbsentTest::IsException => format!("isinstance({target}, BaseException)"),
37+
AbsentTest::Optional | AbsentTest::WrappedOptional => format!("{target} is None"),
38+
AbsentTest::Result => format!("isinstance({target}, BaseException)"),
3939
}
4040
}
4141

@@ -168,21 +168,27 @@ impl<'ast> Visitor<'ast> for Propagate<'_> {
168168
let absent = self
169169
.types
170170
.propagate_absent_test(&unary.operand)
171-
.unwrap_or(AbsentTest::IsNone);
171+
.unwrap_or(AbsentTest::Optional);
172172
let operand_src = self.src(unary.operand.range()).to_owned();
173+
// a wrapped optional's present value is inside the runtime wrapper
174+
let unwrap = if absent == AbsentTest::WrappedOptional {
175+
".value"
176+
} else {
177+
""
178+
};
173179
let (guard, value) = if is_trivially_pure(&unary.operand) {
174180
let cond = absent_condition(absent, &operand_src);
175181
(
176182
format!("if {cond}: return {operand_src}\n{indent}"),
177-
operand_src,
183+
format!("{operand_src}{unwrap}"),
178184
)
179185
} else {
180186
let temp = format!("_prop{}", self.counter);
181187
self.counter += 1;
182188
let cond = absent_condition(absent, &temp);
183189
(
184190
format!("{temp} = {operand_src}\n{indent}if {cond}: return {temp}\n{indent}"),
185-
temp,
191+
format!("{temp}{unwrap}"),
186192
)
187193
};
188194
// hoist the guard immediately before the enclosing statement, then
@@ -235,6 +241,42 @@ mod tests {
235241
assert!(err.contains(needle), "got: {err}");
236242
}
237243

244+
#[test]
245+
fn wrapped_operand_unwraps_value() {
246+
// a wrapped optional's present value lives inside the runtime wrapper:
247+
// the guard returns the absent `None`, the use site reads `.value`
248+
check(
249+
indoc::indoc! {"
250+
def g() -> int??:
251+
return Some(5)
252+
253+
def f() -> int?:
254+
x = g()^
255+
return x
256+
"},
257+
indoc::indoc! {"
258+
class Optional:
259+
def __init__(self, value):
260+
self.value = value
261+
262+
def __class_getitem__(cls, item):
263+
return cls
264+
265+
def __repr__(self):
266+
return f\"Some({self.value!r})\"
267+
268+
def g() -> Optional[int | None]:
269+
return Optional(5)
270+
271+
def f() -> int | None:
272+
_prop0 = g()
273+
if _prop0 is None: return _prop0
274+
x = _prop0.value
275+
return x
276+
"},
277+
);
278+
}
279+
238280
#[test]
239281
fn propagate_outside_function_is_an_error() {
240282
// hoisting a `return` to module scope, into a lambda, or out of a

0 commit comments

Comments
 (0)