From b9bab778a4480a01e16ab1322897e5f694b92962 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 16:06:06 -0400 Subject: [PATCH 1/2] Preserve Boolean values inside comprehension elements --- src/decompiler.rs | 46 +++++++++++++----- .../fixtures/comprehension_boolean_values.py | 38 +++++++++++++++ tests/integration.rs | 25 ++++++++++ .../pyc/comprehension_boolean_values.3.12.pyc | Bin 0 -> 2373 bytes .../pyc/comprehension_boolean_values.3.13.pyc | Bin 0 -> 2553 bytes .../pyc/comprehension_boolean_values.3.14.pyc | Bin 0 -> 2656 bytes 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/comprehension_boolean_values.py create mode 100644 tests/pyc/comprehension_boolean_values.3.12.pyc create mode 100644 tests/pyc/comprehension_boolean_values.3.13.pyc create mode 100644 tests/pyc/comprehension_boolean_values.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..1096b813 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -259,14 +259,16 @@ struct InlineComp { key: Option, cleared_vars: Vec, target_seen: bool, - /// pending element-ternary folds: (merge offset, condition). The + /// Pending value folds: (merge offset, left/condition, optional bool op). + /// An and/or fold combines its saved left with the right value; a ternary + /// combines its condition with both arm values. The /// linear comp walk evaluates BOTH arms of an element ternary onto /// the value stack (then under else); when the walk reaches the /// ternary's merge offset the pair is folded into a Ternary. This /// works for nested ternaries (inner merge folds first) and for /// dict-comp key AND value positions alike (folding at the append /// op cannot tell which slot a pending cond belongs to). - pending_merges: Vec<(usize, ExprRef)>, + pending_merges: Vec<(usize, ExprRef, Option)>, /// else-arm target shared by the current and-chain's ternary links tern_chain_t: Option, } @@ -31631,6 +31633,26 @@ return None; .unwrap_or(false); if let Some(comp) = &mut self.inline_comp { if self.cur_offset < comp.end { + // A 3.12+ value-preserving COPY guard inside an element + // is an and/or expression, rather than a comprehension + // filter. Its fall-through discards the copied left value. + if let Some(&index) = self.idx_of.get(&self.cur_offset) { + let mut previous = index; + while previous > 0 && matches!(self.instrs[previous - 1].op, + Op::TO_BOOL | Op::NOP | Op::NOT_TAKEN | Op::CACHE) + { previous -= 1; } + let copied = previous > 0 + && self.instrs[previous - 1].op == Op::COPY + && self.instrs[previous - 1].arg == 1; + let discard = self.instrs[index + 1..].iter() + .find(|instruction| !matches!(instruction.op, Op::NOP | Op::NOT_TAKEN | Op::CACHE)) + .map_or(false, |instruction| instruction.op == Op::POP_TOP); + if copied && discard && target > self.cur_offset && target < comp.end { + comp.pending_merges.push((target, cond, + Some(if jump_if_true { BoolOpKind::Or } else { BoolOpKind::And }))); + return; + } + } // element ternary guard: same recognition as the // code-object builder (a forward JF between the jump // and its target skips the else arm to a merge, both @@ -31670,7 +31692,7 @@ return None; }); } } else { - comp.pending_merges.push((m_off, cj)); + comp.pending_merges.push((m_off, cj, None)); comp.tern_chain_t = Some(t_off); } return; @@ -52863,20 +52885,22 @@ impl<'a> Ctx<'a> { // arm-skip JFs all land on the append): fold them all, // innermost (LAST recorded) first so the stack pairs line up while let Some(idx) = self.inline_comp.as_ref().and_then(|c| { - c.pending_merges.iter().rposition(|(m, _)| *m == offset) + c.pending_merges.iter().rposition(|(m, _, _)| *m == offset) }) { - let (_, cond) = self + let (_, cond, bool_op) = self .inline_comp .as_mut() .map(|c| c.pending_merges.remove(idx)) .unwrap(); let else_v = self.pop_expr(); - let then_v = self.pop_expr(); - self.push(Rc::new(Expr::Ternary { - cond, - then_expr: then_v, - else_expr: else_v, - })); + if let Some(op) = bool_op { + self.push(Rc::new(Expr::BoolOp { op, values: vec![cond, else_v] })); + } else { + let then_v = self.pop_expr(); + self.push(Rc::new(Expr::Ternary { + cond, then_expr: then_v, else_expr: else_v, + })); + } } } diff --git a/tests/fixtures/comprehension_boolean_values.py b/tests/fixtures/comprehension_boolean_values.py new file mode 100644 index 00000000..5ed4cdf3 --- /dev/null +++ b/tests/fixtures/comprehension_boolean_values.py @@ -0,0 +1,38 @@ +def select(rows): + return [max(0.0, float(row.get("x") or 0.0)) for row in rows] + + +def element_or(rows): + return [row[1] or 0 for row in rows] + + +def element_and(rows): + return [row[1] and 7 for row in rows] + + +def set_values(rows): + return {row[1] or 0 for row in rows} + + +def dict_values(rows): + return {row[0]: row[1] and 7 for row in rows} + + +def calls(rows): + return [row.first() or row.second() for row in rows] + + +def and_calls(rows): + return [row.first() and row.second() for row in rows] + + +def single_filter(rows): + return [row[1] for row in rows if row[0]] + + +def filtered_value(rows): + return [row[1] or 0 for row in rows if row[0]] + + +def ternary_control(rows): + return [row[1] if row[0] else 0 for row in rows] diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..b725319d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,28 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn comprehension_boolean_values_remain_elements_not_filters() { + for version in ["3.12", "3.13", "3.14"] { + let fixture = format!("comprehension_boolean_values.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "return [max(0.0, float(row.get('x') or 0.0)) for row in rows]", + "return [row[1] or 0 for row in rows]", + "return [row[1] and 7 for row in rows]", + "return {row[1] or 0 for row in rows}", + "return {row[0]: row[1] and 7 for row in rows}", + "return [row.first() or row.second() for row in rows]", + "return [row.first() and row.second() for row in rows]", + "return [row[1] for row in rows if row[0]]", + "return [row[1] or 0 for row in rows if row[0]]", + "return [row[1] if row[0] else 0 for row in rows]", + ], + &fixture, + ); + } +} diff --git a/tests/pyc/comprehension_boolean_values.3.12.pyc b/tests/pyc/comprehension_boolean_values.3.12.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44b3d7f97e72365fb333c4e5e112b9d1c25d35dc GIT binary patch literal 2373 zcmb_d&rcgi7@gT&^TTTbDNU7<6q%9+ZwfeIS`0-IgvwF^;(~gqR9UWf0Y{4+W|jmb zka{RmNs!{uN-^RPq+X1;^#4c?W~*A2uQ_px;!~p@`ewarH`tcis4MN;&+Bhz-ur&d zi;fPzObD^Rc<^i2&R;7pVOKBeTIFdN&xlUGCtnkt0;w(mC0z!}x&l;mAJC__09*7{ zV5{B+Y}4C;?VEm0O?vBzWmVi_=tx}>Jnp~1s6B{Mpl9@?Z zC3)Gfj-`^qG7ou47AO`A2Hy-@2Df;4DYap(GlPednbrHuSTWK(l}X3H&16g?k&Zt| znCk`~zQ0*&;f84>EhaPzo;(crj{Gq-T~ud&S=^h=%0GRTi##7c7(a>}PT5z6^O4^! zg?|s+wAGn{GV>=MT){Ux;c?}yD2iA(QJjQvE{fP~n{H8^eu)3vxJZaYM|~WQ<%Kgr z>vc>2B=NTsE;gXRD!t z5(4c|*`eKa`JHJqkWIT-tH-AOO^_g^H?{TXd<< zXV-S;ZB;8M8mMcm8&3Zw+AE@F8%BG-VYFEX7H&e<&kf719CjIQj_Z5Ezm81{-a5Iz z4WpL(lwi~O&)lO1q)H7q!IXcL_Ab`ffTk=kd`6szvP;5h5>Y*sta9vupC|}E06;_s zZx+>&BR8Vi*&KZu+n>*^od$xoI#N(Z5ZIAQDZhWUNllnosV2u62-hld?o~$uT-@u# zeWJj-D8YEBjtb9%5YGq2f4yG#FIaD%i7+1k?#_ zN2A0mV*BAB3da*W7A>k1c_h~pi?)A$_u%f~{AnO$s}luf0%9jvAHHeaIJfZ6x!j_{ zYh#Mjy>_o$k*&@K*+S3P%CvEXcJNes*)-xyDbq3-yN*qTRqv_x-#i zdjE0ER)-485XcYlHhAv%dz5TzPm^rL>P4Uh8jJAGBZ%p)7sEcmQcc?ILWRAdSH=Mg z!Bqs73pwuC9nH<=Bl)$H&)*)tqXlIYT1H(hy-HT?ZK7owi=9gdCTdL!^Lgn=<0OHK zuK%W@6A4Hs*k&AEWwDIOMBfP2TmWQ*BSm#AFK3gvzI`Vdk77rCr-6Q39V;kfP%%~+ z7u{Es1rdBihx@p{EgLo$f p3XcL_$&}L95~a84>o!U?0XM4FsIVpkObQql5GeybW1r&VzX2Qv)GYu2 literal 0 HcmV?d00001 diff --git a/tests/pyc/comprehension_boolean_values.3.13.pyc b/tests/pyc/comprehension_boolean_values.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b59a580097c03ee2692172b35dd5adb3a5b6d48e GIT binary patch literal 2553 zcmcIlOK%%h7`=C9oF*QpB@N|~LUA4tyLsCVaiRbXj-s|nOw}Q5x-iBb2TP8pxpx97 zt8OZ_Nu?TzwhlpRS_#RnghVQ_fM4On5*6OE$_~{lcAPt7Cn>f=rHYZ}`18y+_kQPm zk6UhSb;^X0>m9%Ru>EPM|4-Qa7p++3b1?1_jdT!3Gzz4e1e7!xC~FE((HuaB)&y+Q zjscHp&A?`@1=zCa^s2GydSY1>x7fIR6L6O-kaqGJ<C_POM|A)O+J)Ju9_=M=R+}L=_=E|)YCj^ zq$6J#MoN#SBVR>RD>@&%dacyN^^_hnnb1H)&HaGu7TXRp^W_9Le~Fa zY-4Q8zd8Bz^kB|kJT>&YYxvjc9d)XpO#Qv9PH_d_-5C2wc}`iOj-~8*7&XeiLWt3( zQQ=ghlmul?)>kGkhxbINd$>s+$;D5FK6An@IYDVjPn!{gu{Jyq8e1-eq=V14YR>oMdbCv zcXHjV)EavOZk8*uN@`fojt86;qK~j!5&6sBSMQVRk}n zEA(ck1k?{$jVELK5qH2(ln?g;P&)m?MRnxy^p>S}CX}V$&fK5NF7LYBdB{FeP(~p8 zh}{UMyBk!{H(2X{US-5!1EcM-sOf)Qqm}=M+5>^R2?o#p5IXpz>W$Xvt1m>JhA}l9 zi6rBuX#JRhx~l0PM=Y93apuO#LMz@60Du2jQ4Kx`fJ^Y1q(_L5(Mog8V>$X5=3800`?Zzu(t)&5N|=7MQrljaCj4Um3$yrRL7r8=d>z$ zPd4=6;>N|zxm}lM8~GnEDB~bM&N}f`{k99^O-=66rPU#L_^_Bqsa8uJ`9vP-6*flU zp~9|T)2%$2UQFqcL^5USjJ<~-1bf~CKvvIm71e?7;~Ado{4udUa}$H{_RL0nM;$0A z10XcOo8fq)wM)6z($yfNpTnbu7N-o+*j+V;53O^@5dh6y1> z8u(d2W$63F(9bRZ^#1y+;COiW`wEH=OnnumeagL)eGQntXbPhkQPuQ)LN&pwK?;xJ zbpi=XN7=Oq`qX4biU~eK2i|KF8}b*`z+*EfXFKmF1g*81t%`DfJ3CyKnh~H7;O6z@Pws88FH)sK`H>uf{O| literal 0 HcmV?d00001 diff --git a/tests/pyc/comprehension_boolean_values.3.14.pyc b/tests/pyc/comprehension_boolean_values.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf57c91758e134ecfa96ced4352d7bfebf34ad72 GIT binary patch literal 2656 zcmcIm&rcgi6rQoi*mzAKO=;Rd`4OkF%?}&!77RsA3CL1n7tuxGgm4{uO|2RmW|o%p z#3d3~IK{D%Gx3H5~I_EVh)WzV_5x6ng2cZ^ky@f}=ETNAkC$-PzgqeeZj3 z+<&gcYD5Ta#-1;3cK-0mZ!kX^xH>z$4wFr!po>UD3I@cA0nnfr0gVa)B#H^pq%;9G zDP};kVga-$&4A5{)s9?Jq_!j9Sb`t_^x+o3CR#vk=oVf#xY4@7Ka9{SUOZZ@phdn? zj0HSrs?CqD3YM#@KfsRa?PYkU6Lmw}wP&)S@@Uo&@FLWVy5L)@*bdk&W^v>)EY~(I z;|-UwY+MO%lufZjDy)@_%c@o|lnG6J$;w8Ke7~zbWw)j>jd^164Q-vO%o9nitWxze zHOb1689qBO-U@akR1HS%-oHBzT9S4rqQ zCpNFsw;iFi^GkT(57&C!_RDSkeqXRwZ=BS(042nasQ(6x<4?*X{*#kRO*$TtLW0-+FEsq-v1z&4rbPhww?lH zVOS@_kcHt&m2~uzoVmY&t-caF)>TO#XF7M(gtp@`R zG}2%eI*Tb5r%dDRA(8^KowA}S5>6x-?SbvVDTsLhKn3quN%HN>`RSb-U*Gs9@XY4W zZ4PK26WNK~$)e4%4+ZAai4Tp|56Fs38W~P?2J-uh7t@_G_b>h zci>mkK=zfS@%@?nd`$zVZgXa)AKlE}+zl3O&V6vhxK74FgK^pmFP>eKOYr2_@8T?i zc%WC-`P~7l_^6yPfeQ%oUS9!_>8`UgP2YwW8?>4h7EdlG)KDy*&{Rs_gOywe<^+H| zcU>+?gV|`B<$HgOg$j^ zVgFi>XHquT+_y#53k(a8w# zA7M6lx|*$di%B>*YQ&>#~YJ6XL)Xhm`2> zl=n-~E-0@TVJM-k_?|G~A>2omgWEE>l8UY;)DJ1|uDtCrAHZ`X#`uK+ Date: Tue, 13 Oct 2026 18:34:45 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_comprehension_boolean_values.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/behavior/cases/b_comprehension_boolean_values.py diff --git a/tests/behavior/cases/b_comprehension_boolean_values.py b/tests/behavior/cases/b_comprehension_boolean_values.py new file mode 100644 index 00000000..bdfc5ead --- /dev/null +++ b/tests/behavior/cases/b_comprehension_boolean_values.py @@ -0,0 +1,27 @@ +# MIN_VERSION: 3.12 + + +def select(rows): + return [max(0.0, float(row.get("x") or 0.0)) for row in rows] + + +def calls(rows): + return [row.first() or row.second() for row in rows] + + +def and_calls(rows): + return [row.first() and row.second() for row in rows] + + +print(select([{}, {"x": None}, {"x": 0}, {"x": -2}, {"x": 7}])) +class Row: + def __init__(self, value): + self.value = value + def first(self): + print("first", self.value) + return self.value + def second(self): + print("second", self.value) + return 9 +print(calls([Row(0), Row(3)])) +print(and_calls([Row(0), Row(3)]))