From 5adb15c2b380c94edac207cddc2bc05244ca48ca Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 15:55:51 -0400 Subject: [PATCH 1/2] Preserve nested conditional values in comprehension elements --- docs/LIMITATIONS.md | 5 +-- src/decompiler.rs | 30 +++++++++++++----- tests/fixtures/nested_conditional_elements.py | 20 ++++++++++++ tests/integration.rs | 19 +++++++++++ .../pyc/nested_conditional_elements.3.12.pyc | Bin 0 -> 1483 bytes .../pyc/nested_conditional_elements.3.13.pyc | Bin 0 -> 1602 bytes .../pyc/nested_conditional_elements.3.14.pyc | Bin 0 -> 1671 bytes 7 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/nested_conditional_elements.py create mode 100644 tests/pyc/nested_conditional_elements.3.12.pyc create mode 100644 tests/pyc/nested_conditional_elements.3.13.pyc create mode 100644 tests/pyc/nested_conditional_elements.3.14.pyc diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 6c90274a..ba9ea9c6 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -62,8 +62,9 @@ equivalent, or (b) a corner with no occurrence in the 520-module corpus. - `with a, b:` renders as nested with statements (equivalent). - Async gaps: inlined async comprehensions, async-generator asend/athrow protocol, the 3.7 SETUP_EXCEPT-guarded async-for break+else combination. -- Ternaries inside comprehension elements can be misplaced on some old - versions. +- Nested ternaries in either arm of an inlined comprehension element are + reconstructed on 3.12+. Ternaries inside comprehension elements can + still be misplaced on older versions, including 3.8–3.11. - py2 deep-nesting shape "if containing an if + sibling elif + dangling tail return" may lose subsequent branches. - A few 3.3 modules (_weakrefset/copy/copyreg) are AST-PASS with an empty diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..f8e3b52e 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -51110,10 +51110,16 @@ fn genexpr_ternary_merge( | Op::POP_JUMP_IF_TRUE | Op::POP_JUMP_FORWARD_IF_FALSE | Op::POP_JUMP_FORWARD_IF_TRUE + | Op::POP_JUMP_IF_NONE + | Op::POP_JUMP_IF_NOT_NONE + | Op::POP_JUMP_FORWARD_IF_NONE + | Op::POP_JUMP_FORWARD_IF_NOT_NONE ) }; let mut jf_target = None; - for x in instrs.iter().skip(ii + 1) { + let mut j = ii + 1; + while j < instrs.len() { + let x = instrs[j]; if x.offset >= t { break; } @@ -51130,6 +51136,18 @@ fn genexpr_ternary_merge( break; } if is_cj(x.op) && x.target == Some(t) && !x.is_backward { + j += 1; + continue; + } + // A then-arm value may itself contain a conditional dictionary or + // another ternary. Consume its complete, pure value region before + // searching for this arm's final jump, just as the else-arm does. + if is_cj(x.op) { + let nested_merge = Self::genexpr_ternary_merge(instrs, j)?; + if nested_merge > t { + return None; + } + j = instrs.iter().position(|item| item.offset == nested_merge)?; continue; } // 3.12+: TO_BOOL sits between the value run and the cond jump @@ -51137,11 +51155,13 @@ fn genexpr_ternary_merge( // element rendered as a plain `x`, silently dropping the else // arm on EVERY version with the folded ternary shape) if matches!(x.op, Op::TO_BOOL | Op::NOP | Op::NOT_TAKEN | Op::CACHE) { + j += 1; continue; } if !Self::comp_arm_value_op(x.op) { return None; } + j += 1; } let m = jf_target?; let ti = instrs.iter().position(|x| x.offset == t)?; @@ -51157,13 +51177,7 @@ fn genexpr_ternary_merge( j += 1; continue; } - if matches!( - x.op, - Op::POP_JUMP_IF_FALSE - | Op::POP_JUMP_IF_TRUE - | Op::POP_JUMP_FORWARD_IF_FALSE - | Op::POP_JUMP_FORWARD_IF_TRUE - ) { + if is_cj(x.op) { let nm = Self::genexpr_ternary_merge(instrs, j)?; let nmi = instrs.iter().position(|x2| x2.offset == nm)?; if nmi > mi { diff --git a/tests/fixtures/nested_conditional_elements.py b/tests/fixtures/nested_conditional_elements.py new file mode 100644 index 00000000..001331f8 --- /dev/null +++ b/tests/fixtures/nested_conditional_elements.py @@ -0,0 +1,20 @@ +def select(rows): + return [{ + "excluded": ({"count": row["count"], + "reasons": loads(row["reasons"]) if row["reasons"] else {}} + if row["count"] is not None else {}), + "archived": str(row["archived"]) if row["archived"] else None, + } for row in rows] + + +def then_arm(rows, build): + return [build(1 if row.inner else 2) if row.outer else 3 for row in rows] + + +def else_arm(rows, build): + return [1 if row.outer else build(2 if row.inner else 3) for row in rows] + + +def both_arms(rows, build): + return [build(1 if row.inner is None else 2) if row.outer + else build(3 if row.inner is not None else 4) for row in rows] diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..e686340a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,22 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn nested_conditional_comprehension_arms_keep_their_values() { + for version in ["3.12", "3.13", "3.14"] { + let fixture = format!("nested_conditional_elements.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "return [{'excluded': {'count': row['count'], 'reasons': loads(row['reasons']) if row['reasons'] else {}} if row['count'] is not None else {}, 'archived': str(row['archived']) if row['archived'] else None} for row in rows]", + "return [build(1 if row.inner else 2) if row.outer else 3 for row in rows]", + "return [1 if row.outer else build(2 if row.inner else 3) for row in rows]", + "return [build(1 if row.inner is None else 2) if row.outer else build(3 if row.inner is not None else 4) for row in rows]", + ], + &fixture, + ); + } +} diff --git a/tests/pyc/nested_conditional_elements.3.12.pyc b/tests/pyc/nested_conditional_elements.3.12.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71b448527f9189d9ce61dc65fd59436993bd2094 GIT binary patch literal 1483 zcmb_cT}V?=96#sWd*^hU=GUequ~mel@&n0K%n*}W=7Q=e2$}cXay9SObMMlDhC)6F zK`jC&1Sv$QJtVy}dh#*oMN>leB=qFl+Cb2!{^z#Y6!RfEaDV6jb^ho6zs^^Oqd-6i zy>mTUIeB^`{sZW}g)>yT3WzC0(NlC6Q4EMF0VGra6e6NmlC!q_h!sBWG#vw&LL=x3 zdX8oC0LkQD3qqrK1XElIy7A*#3U{+P641NdyXOr>H}u;3!C@oJ6unjjR-rwzrqZAh z)YL$zM+qtYifZUJ1CfM92TnGa9ZvIL^$@@qO4?D0bK54_UC9DuD@;nLvMP3U+P+*- z2ekXt%*E-E^_ENV;OCmRilA6*e!h zd=Mw3-f%EPSpm>Gw{yGQ%*GW~qfb$L0<51^0Bh=NfEYvT*KT~bbuX39b$swG_$E7F z=u?rGk*H@Tux780*RR-HzuCH%#qJ&0(!3oe&Nlp?)bQ}jk4z8rHx;t!IXpG22!4r` za&Q712wDm0iW&5A&=qq1{-9r!PF23FoNS+!<{CaUFEq~v;+|D^>%O2FAFjiDA!n$w zy}*b1A9`WzU@shzm~nV)ATeT2VtxM4LcL2uFW~Xmk!1gY@-qxNJ(1oT?zK@wciAI)P)lttJv`XDZu`|~3#y9O-uBwj? zE)K31H~$o{qa}s-*(AbJ7lsMr40C{EzgIMe-0Vlj>tkL#kJ3OQvuSs&t^FDehml$n~1p*F~stf4>OD&K_2_b@3G^NF%HWe!>RXrg848@Y!O=i{! zNL7_fq~unlM!=~c^#D>O=TIr!l3RaH;pY(c)T$?L#+yrSdC#$3tOnd=gvl(VF1HEYzpk`ywX|4}HYN@vYzN_c0c`sCFrr01@ zT0f^Aefmo)X$O^7^Zvim0?X*AsMLRMrY?Eoi?ypzf*PgAbMkuIH^I3VQcH%;#&S14Bv z%;gr{f)r}N#-Yg0W*j@4b90WJ%Fl|tSP-_W`mZf{0fnQCE5o>&j;<8KEjS88Ylj~) z!qIXwT6fNt4Cw4${c z&&5kmJ{-8;T%7v(%Br?{b?wyt>Apv&pEenhz!7CUqTD4gjOep6ps51*-`iu6v35N} zC#f}>CT9FQ2-RZqda(xqoZjhL?6JOHn(% zHf8zU_0n;gP@eolxSj zo_Mw!O86w0+e4qeS<+t6(e;|d;T^HG8gVC+Ch@+3Y$a$%XrM#nm8j6sg+7W@jdZMZ zu5{iV+b}lI7l(_(8>#J{J#(ai4tWMwzFI%Oq30$2e1?w&M{S_5vDLMK^*Nac`d5Q; zI!=yGQ?!P3>Bs2${M8zZ8A*8xHznU8XhArnTAxHASdx{4gnp2-NhaUNfaf+|Ef4?z literal 0 HcmV?d00001 diff --git a/tests/pyc/nested_conditional_elements.3.14.pyc b/tests/pyc/nested_conditional_elements.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a5fc236a238f3b73f8ccd7ae0207be7d0f153ad GIT binary patch literal 1671 zcmb_cO>7%Q6n?Wm!H%8KCXHQ_kj9M&u?lemq0}fsilR`KV7FS)TtJB&?^Lnmtdp5F z)Dl9zAkm%@slrAgB9%aLL?wq)eL$kz=#4r#G<#~r0U`C^*j&nqH(T4atfu0?Nc-)Z znKy6VeD9k%)7#T613>M3)dHT}G63WfVec zqUkLbdGHfq>yHp}umH#4GRa62kdfX?0IU%m7Klz>dkLo-jfMe+FnZ^>F5`&Y?)hqC zh0!loeA3#t({HLV6D45?O9FKTeU#_K5;;4q)9-04T?$JvX`TYOVHnS|Mn(}&IaU&R zuwC?OB9$?yVXKLB#)+C}9F) zBlhbSTb2C%L#ud zHmZWE`XAQ-O@0iM_%bvzsgzzp_;nzFm=FhSwk|XrpthHuLccNl_25+#e$(?TxFSjMSV8KqVkr6lZ-zXXXpSf+HwqRdv4 zrb*e3%-lF^tIL^W%3$4SbCLbn*a#Dnw_Vl>HJ9RSl?`Cmna3UQU3QBRAva z;NHmeGpRX)to-CXlA2xIp+it}`0_|76`bj8EvW>aFR3d{^4g{3KblDT#9u}Nl{Nw| zJX>}l5<7}M_j1}=5%h6}DMBB9{9}Y1JeR*ZK&Fk(x{3-!6&F J6E=tr{{lfXJL&)c literal 0 HcmV?d00001 From 9b815099ec738e18695c5e9d63e25d86a8ff8aa4 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:34:26 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_nested_conditional_elements.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/behavior/cases/b_nested_conditional_elements.py diff --git a/tests/behavior/cases/b_nested_conditional_elements.py b/tests/behavior/cases/b_nested_conditional_elements.py new file mode 100644 index 00000000..f3578c49 --- /dev/null +++ b/tests/behavior/cases/b_nested_conditional_elements.py @@ -0,0 +1,18 @@ +# MIN_VERSION: 3.12 + + +def select(rows): + return [{ + "excluded": ({"count": row["count"], + "reasons": loads(row["reasons"]) if row["reasons"] else {}} + if row["count"] is not None else {}), + "archived": str(row["archived"]) if row["archived"] else None, + } for row in rows] + + +def loads(value): + print("loads", value) + return {"decoded": value} +print(select([{"count": 1, "reasons": "why", "archived": 3}, + {"count": None, "reasons": "skip", "archived": 0}, + {"count": 2, "reasons": "", "archived": None}]))