Skip to content
Closed
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
46 changes: 43 additions & 3 deletions src/decompiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28791,6 +28791,15 @@ return None;
if st_t.len() != st_e.len() || st_t.is_empty() {
return None;
}
// CALL_KW support must not make an arm-local callable/argument
// prefix look like a preload from before the condition. Merging
// only its final slot would hoist that prefix across the truth
// test. Decline this shape until the whole arm can be preserved.
if st_t.len() != base.len() + 1
&& self.instrs[tstart..=re].iter().any(|ins| ins.op == Op::CALL_KW)
{
return None;
}
let slot_t = st_t.last()?.clone();
let slot_e = st_e.last()?.clone();
// compare the UNDER-stack only — the tops are the slot values
Expand Down Expand Up @@ -28917,11 +28926,19 @@ return None;
(
Expr::Call { func: fa, args: aa, keywords: ka, star_args: sa, star_kwargs: ska },
Expr::Call { func: fb, args: ab, keywords: kb, star_args: sb, star_kwargs: skb },
) if ka.is_empty() && kb.is_empty() && sa.is_none() && sb.is_none() && ska.is_none() && skb.is_none() && aa.len() == ab.len() => {
) if sa.is_none() && sb.is_none() && ska.is_none() && skb.is_none()
&& aa.len() == ab.len() && ka.len() == kb.len()
&& ka.iter().zip(kb).all(|((a, _), (b, _))| a == b) => {
let mut slots: Vec<(&ExprRef, &ExprRef)> = vec![(fa, fb)];
for (x, y) in aa.iter().zip(ab.iter()) {
slots.push((x, y));
}
// CALL_KW can now reach this merge through the bounded
// walker. Keep keyword values in the same ordered slot
// model as positional values, preserving the call itself.
for ((_, x), (_, y)) in ka.iter().zip(kb) {
slots.push((x, y));
}
let mut merged_idx: Option<usize> = None;
let mut merged_val: Option<ExprRef> = None;
for (i, (x, y)) in slots.iter().enumerate() {
Expand All @@ -28940,8 +28957,9 @@ return None;
reps[idx] = val;
Some(Rc::new(Expr::Call {
func: reps[0].clone(),
args: reps[1..].to_vec(),
keywords: Vec::new(),
args: reps[1..1 + aa.len()].to_vec(),
keywords: ka.iter().zip(&reps[1 + aa.len()..])
.map(|((name, _), value)| (name.clone(), value.clone())).collect(),
star_args: None,
star_kwargs: None,
}))
Expand Down Expand Up @@ -29111,6 +29129,28 @@ return None;
star_kwargs: None,
}));
}
Op::CALL_KW => {
let names = st.pop()?;
let Expr::Const(names) = &*names else { return None; };
let PyObject::Tuple(names) = &**names else { return None; };
let names: Option<Vec<String>> = names.iter().map(|value| match &**value {
PyObject::Str(name) => Some(name.clone()), _ => None,
}).collect();
let names = names?;
if names.len() > arg || st.len() < arg + 1 { return None; }
let all = st.split_off(st.len() - arg);
let positional = arg - names.len();
let args = all[..positional].to_vec();
let keywords = names.into_iter().zip(all[positional..].iter().cloned())
.map(|(name, value)| (Some(name), value)).collect();
let is_null = |value: &ExprRef| matches!(&**value, Expr::Name(name) if name == "\u{0}null");
let mut callable = st.pop()?;
if is_null(&callable) { callable = st.pop()?; }
if st.last().map_or(false, is_null) { st.pop(); }
st.push(Rc::new(Expr::Call {
func: callable, args, keywords, star_args: None, star_kwargs: None,
}));
}
Op::BINARY_OP => {
let r = pop1(&mut st);
let l = pop1(&mut st);
Expand Down
25 changes: 25 additions & 0 deletions tests/behavior/cases/b_keyword_conditional_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# MIN_VERSION: 3.13


def case_0(value, build, other):
return build("reject" if value else "accept", amount=0)


def case_1(value, build, other):
return build("prefix", amount=0 if value else 1)


def case_9(value, build, other):
return build("prefix", amount=1 if value else 2, last=other(value))


def build(*args, **kwargs):
print("build", args, sorted(kwargs.items()))
return args
def other(value):
print("other", value)
return 8
print(case_0(False, build, other))
print(case_0(True, build, other))
print(case_1(False, build, other))
print(case_9(True, build, other))
5 changes: 5 additions & 0 deletions tests/fixtures/keyword_call_arm_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Resolving owner.build belongs after the condition. Reconstructing this as
# outer(owner.build(amount=1 if value else 2)) changes descriptor evaluation
# order. The bounded merger must decline rather than report a clean result.
def select(value, owner, outer):
return outer(owner.build(amount=1) if value else owner.build(amount=2))
22 changes: 22 additions & 0 deletions tests/fixtures/keyword_conditional_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Versioned .pyc files are consumed by tests/integration.rs.

# CALL_KW starts in Python 3.13.

def case_0(value, build, other):
return build("reject" if value else "accept", amount=0)


def case_1(value, build, other):
return build("prefix", amount=0 if value else 1)


def case_2(value, build, other):
return build(0 if value else 1, amount=value)


def case_9(value, build, other):
return build("prefix", amount=1 if value else 2, last=other(value))


def case_11(value, build, other):
return other(build(amount=1) if value else build(amount=2, other=3))
30 changes: 30 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,33 @@ fn py314_hello() {
"py3.14 hello",
);
}

#[test]
fn keyword_calls_survive_conditional_value_reconstruction() {
for version in ["3.13", "3.14"] {
let (src, clean) = decompile_fixture(&format!("keyword_conditional_calls.{version}.pyc"));
assert!(clean, "Python {version}: output marked incomplete:\n{src}");
assert_contains(&src, &[
r#"def case_0(value, build, other):
return build('reject' if value else 'accept', amount=0)"#,
r#"def case_1(value, build, other):
return build('prefix', amount=0 if value else 1)"#,
r#"def case_2(value, build, other):
return build(0 if value else 1, amount=value)"#,
r#"def case_9(value, build, other):
return build('prefix', amount=1 if value else 2, last=other(value))"#,
r#"def case_11(value, build, other):
return other(build(amount=1) if value else build(amount=2, other=3))"#,
], version);
}
}

#[test]
fn keyword_call_merge_does_not_hoist_an_arm_local_callable() {
for version in ["3.13", "3.14"] {
let (src, clean) = decompile_fixture(&format!("keyword_call_arm_boundary.{version}.pyc"));
assert!(!clean, "Python {version}: an unproved call merge must remain incomplete:\n{src}");
assert!(!src.contains("owner.build(amount=1 if value else 2)"),
"Python {version}: callable resolution moved before the condition:\n{src}");
}
}
Binary file added tests/pyc/keyword_call_arm_boundary.3.13.pyc
Binary file not shown.
Binary file added tests/pyc/keyword_call_arm_boundary.3.14.pyc
Binary file not shown.
Binary file added tests/pyc/keyword_conditional_calls.3.13.pyc
Binary file not shown.
Binary file added tests/pyc/keyword_conditional_calls.3.14.pyc
Binary file not shown.