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
37 changes: 34 additions & 3 deletions src/decompiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42539,9 +42539,39 @@ fn cmp_from_index(idx: usize) -> CmpOp {
// ===================== stack events, calls, functions, classes =====================

impl<'a> Ctx<'a> {
fn discarded_unpack_target(&self, remaining: usize) -> ExprRef {
// Repeated local targets can have their earlier stores replaced by
// POP_TOP. Their names may then be obfuscated while the surviving
// store keeps its slot. Reuse the next direct local store's spelling:
// the compiler elides the duplicate instead of creating an extra `_`
// local. Do not cross nested unpacking, side effects or control flow.
let name = if self.version.at_least(3, 13) {
self.idx_of.get(&self.cur_offset).and_then(|&index| {
let mut discarded = 1usize;
for next in &self.instrs[index + 1..] {
match next.op {
Op::NOP | Op::CACHE | Op::EXTENDED_ARG | Op::NOT_TAKEN => {},
Op::POP_TOP => {
discarded += 1;
if discarded >= remaining { return None; }
}
Op::STORE_FAST => return self.code.varnames.get(next.arg as usize).cloned(),
Op::STORE_FAST_STORE_FAST | Op::STORE_FAST_LOAD_FAST => {
return self.code.varnames.get(((next.arg >> 4) & 0xF) as usize).cloned();
}
Op::STORE_DEREF => return self.code.deref_name(next.arg as usize).map(str::to_string),
_ => return None,
}
}
None
})
} else { None };
self.name_expr(name.as_deref().unwrap_or("_"))
}

fn handle_pop_top(&mut self) {
// 3.14 `_` elision: the compiler replaces the store of a
// throwaway `_` name inside a tuple unpack with a POP_TOP
// 3.13+ repeated-target elision: the compiler replaces an
// overwritten local store inside a tuple unpack with a POP_TOP
// discarding that slot (UNPACK_SEQUENCE; POP_TOP;
// STORE_FAST_STORE_FAST). The open unpack frame must consume
// the slot as a placeholder target or the tuple never
Expand All @@ -42552,8 +42582,9 @@ impl<'a> Ctx<'a> {
if *remaining > 0 {
let is_slot = matches!(self.stack.last(), Some(Sv::E(e)) if Rc::ptr_eq(e, fv));
if is_slot {
let target = self.discarded_unpack_target(*remaining);
if let Some(Sv::E(_)) = self.stack.pop() {
self.collect_unpack_target(self.name_expr("_"));
self.collect_unpack_target(target);
}
return;
}
Expand Down
31 changes: 31 additions & 0 deletions tests/behavior/cases/b_repeated_unpack_bindings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# MIN_VERSION: 3.13


def underscore_loop(values):
result = []
for value, _, discarded, discarded in values:
result.append((value, discarded, _))
return result


def underscore_list(values):
return [(value, discarded, _) for value, _, discarded, discarded in values]


def nested_loop(values):
result = []
for value, (discarded, discarded) in values:
result.append((value, discarded))
return result


def nested_list(values):
return [(value, discarded) for value, (discarded, discarded) in values]


rows = [(1, 2, 3, 4), (5, 6, 7, 8)]
print(underscore_loop(rows))
print(underscore_list(rows))
nested = [(1, (2, 3)), (4, (5, 6))]
print(nested_loop(nested))
print(nested_list(nested))
35 changes: 35 additions & 0 deletions tests/fixtures/repeated_unpack_bindings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Versioned .pyc files are consumed by tests/integration.rs.

# CPython 3.13 starts replacing overwritten stores with POP_TOP.

def repeated_loop(values):
result = []
for value, discarded, discarded, discarded in values:
result.append(value is not None)
return result


def repeated_list(values):
return [value is not None for value, discarded, discarded, discarded in values]


def underscore_loop(values):
result = []
for value, _, discarded, discarded in values:
result.append((value, discarded, _))
return result


def underscore_list(values):
return [(value, discarded, _) for value, _, discarded, discarded in values]


def nested_loop(values):
result = []
for value, (discarded, discarded) in values:
result.append((value, discarded))
return result


def nested_list(values):
return [(value, discarded) for value, (discarded, discarded) in values]
31 changes: 31 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,34 @@ fn py314_hello() {
"py3.14 hello",
);
}

#[test]
fn overwritten_unpack_slots_reuse_the_surviving_binding() {
for version in ["3.12", "3.13", "3.14"] {
let (src, clean) = decompile_fixture(&format!("repeated_unpack_bindings.{version}.pyc"));
assert!(clean, "Python {version}: output marked incomplete:\n{src}");
assert_contains(&src, &[
r#"def repeated_loop(values):
result = []
for value, discarded, discarded, discarded in values:
result.append(value is not None)
return result"#,
r#"def repeated_list(values):
return [value is not None for value, discarded, discarded, discarded in values]"#,
r#"def underscore_loop(values):
result = []
for value, _, discarded, discarded in values:
result.append((value, discarded, _))
return result"#,
r#"def underscore_list(values):
return [(value, discarded, _) for value, _, discarded, discarded in values]"#,
r#"def nested_loop(values):
result = []
for value, (discarded, discarded) in values:
result.append((value, discarded))
return result"#,
r#"def nested_list(values):
return [(value, discarded) for value, (discarded, discarded) in values]"#,
], version);
}
}
Binary file added tests/pyc/repeated_unpack_bindings.3.12.pyc
Binary file not shown.
Binary file added tests/pyc/repeated_unpack_bindings.3.13.pyc
Binary file not shown.
Binary file added tests/pyc/repeated_unpack_bindings.3.14.pyc
Binary file not shown.