From f317eef3a213025660f9b5f39699cc769451517b Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 15:05:42 -0400 Subject: [PATCH 1/2] Distinguish empty call defaults from comprehension accumulators --- src/decompiler.rs | 12 +++- .../comprehension_default_arguments.py | 66 ++++++++++++++++++ tests/integration.rs | 24 +++++++ .../comprehension_default_arguments.3.11.pyc | Bin 0 -> 4396 bytes .../comprehension_default_arguments.3.12.pyc | Bin 0 -> 2745 bytes .../comprehension_default_arguments.3.13.pyc | Bin 0 -> 2812 bytes .../comprehension_default_arguments.3.14.pyc | Bin 0 -> 2785 bytes 7 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/comprehension_default_arguments.py create mode 100644 tests/pyc/comprehension_default_arguments.3.11.pyc create mode 100644 tests/pyc/comprehension_default_arguments.3.12.pyc create mode 100644 tests/pyc/comprehension_default_arguments.3.13.pyc create mode 100644 tests/pyc/comprehension_default_arguments.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..9e0bcc7f 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -52942,7 +52942,17 @@ impl<'a> Ctx<'a> { continue } Op::BUILD_LIST | Op::BUILD_SET | Op::BUILD_MAP => { - return ins.arg == 0; + if ins.arg != 0 { + return false; + } + // Modern accumulators are lifted under the iterator + // by SWAP 2. An empty default argument consumed by a + // preceding CALL is not a new comprehension level. + // Preserve the distinct py2 prologue handled above. + return !self.version.at_least(3, 12) + || self.instrs.get(back + 1).map_or(false, |next| { + next.op == Op::SWAP && next.arg == 2 + }); } o if is_pure_value_op(o) || matches!( diff --git a/tests/fixtures/comprehension_default_arguments.py b/tests/fixtures/comprehension_default_arguments.py new file mode 100644 index 00000000..254bc415 --- /dev/null +++ b/tests/fixtures/comprehension_default_arguments.py @@ -0,0 +1,66 @@ +# Compile with each named CPython version; the committed .pyc files are +# exercised by the existing Rust integration suite. + +# Python 3.12+ inlines these comprehensions; 3.11 is the legacy control. + +def list_default(rows): + row = "saved-row" + field = "saved-field" + result = [field for row in rows for field in row.get("values", [])] + return result, row, field + + +def set_default(rows): + row = "saved-row" + field = "saved-field" + result = {field for row in rows for field in row.get("values", {})} + return result, row, field + + +def dict_default(rows): + row = "saved-row" + field = "saved-field" + result = {field: len(field) for row in rows for field in row.get("values", {})} + return result, row, field + + +def nested_list(rows): + row = "saved-row" + field = "saved-field" + result = [[field for field in row.get("values", [])] for row in rows] + return result, row, field + + +def nested_set(rows): + row = "saved-row" + field = "saved-field" + result = [{field for field in row.get("values", {})} for row in rows] + return result, row, field + + +def nested_dict(rows): + row = "saved-row" + field = "saved-field" + result = [{field: len(field) for field in row.get("values", {})} for row in rows] + return result, row, field + + +def subscript(rows): + row = "saved-row" + field = "saved-field" + result = [field for row in rows for field in row["values"]] + return result, row, field + + +def no_default(rows): + row = "saved-row" + field = "saved-field" + result = [field for row in rows for field in row.get("values")] + return result, row, field + + +def tuple_default(rows): + row = "saved-row" + field = "saved-field" + result = [field for row in rows for field in row.get("values", ())] + return result, row, field diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..24cb7561 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,27 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn empty_defaults_keep_the_comprehension_level() { + // PEP 709 inlining starts in 3.12; retain the 3.11 compatibility case. + for version in ["3.11", "3.12", "3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("comprehension_default_arguments.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + for (name, expression) in [ + ("list_default", "[field for row in rows for field in row.get('values', [])]"), + ("set_default", "{field for row in rows for field in row.get('values', {})}"), + ("dict_default", "{field: len(field) for row in rows for field in row.get('values', {})}"), + ("nested_list", "[[field for field in row.get('values', [])] for row in rows]"), + ("nested_set", "[{field for field in row.get('values', {})} for row in rows]"), + ("nested_dict", "[{field: len(field) for field in row.get('values', {})} for row in rows]"), + ("subscript", "[field for row in rows for field in row['values']]"), + ("no_default", "[field for row in rows for field in row.get('values')]"), + ("tuple_default", "[field for row in rows for field in row.get('values', ())]"), + ] { + let expected = format!( + "def {name}(rows):\n row = 'saved-row'\n field = 'saved-field'\n result = {expression}\n return result, row, field"); + assert_contains(&src, &[&expected], version); + } + } +} diff --git a/tests/pyc/comprehension_default_arguments.3.11.pyc b/tests/pyc/comprehension_default_arguments.3.11.pyc new file mode 100644 index 0000000000000000000000000000000000000000..59434d0668a1bcf5ea24f9ff390129b7c4234814 GIT binary patch literal 4396 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 D9tJw? literal 0 HcmV?d00001 diff --git a/tests/pyc/comprehension_default_arguments.3.13.pyc b/tests/pyc/comprehension_default_arguments.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efab974191f5670f1e9520e3aac23e7985b4d00f GIT binary patch literal 2812 zcmcIm&u<$=6rSDnkNC$r1lI~FxCw3`wGd8R5>gOpN$jMFL_(B}n*tJ1u-C4UvyNug zq1%E6wM(yKiRa zeg58iQxQeGKnTg6+W30?_uGH}33mPBbgS|<7`KT;P7_Kb4v>?0Kwc661<3?xlFWc+ z$pUDRtbkUj4X`a?^V_4fm2kl}82HJ`bpTkEQ(!fPj}SMx!d>B+&`%ieCX-%5(j9Y$ zXM#Uo5_k;pRoe}-CQab!n-!)lgg8hKneMLsDuTkny21jtzz+(&uxbxsg2F3Yoa9Y2 zPi$;yRgS$*)1N(YRLyu&PQ}WW)umKMW_hkGBxE&jDx1KCl?A}ES$Cl_%cN8WUnV_p zm4m9xRMsC)URN{lu0NVyUZL_eSz*bvvKW)&OW?k^L=%~1Sy5SF<@2&Lm1Jt|6>W!= zVE?2t4zNb{?4GsIp0jIh{EzOlCGqlCYEJm6^M2>n3)|;+gA*I(tXv3A+>pOi*Cq<$ z|pW%FK&1uqT_Q29RP-KaJnX@FTiLdrh#fWea*%6R5-AOwC^KaQ@bMkkS@VRVH)X$yl%vuo`}88xjUSQkh;7+ z1C$2|$=4E+L3+d8t1VaqkGLU0kCBK1=_bb(F@q+KTxQjS8wB|aGRGy*beh|&QMv{d^Rj23bU zG?&ZMQR>OrJmvUW%IOjsIJty87)+xoqeqS@NTFj8NNU1905C{scD^J=inH?%-9IGq ziOp-MQL(Hr6>wu zp0E)B13TE8E@5xly!xXTo6>!6ttVYFvVcA5T~4d?*-pPPU~B|hz^h=i2>7Mu%BmIV zhD4nCmqaw}NOmP%Z`8f8$6**O1aO3m&_Sc#4USdY^jM9O&{0alIwfa^jFi+%%CjGm zB-4LAB>BxV)C5sdlo&->(w7mMHEvPOtfb^RQ&8{8Mf3-uR$KRs-=-aOC{|U9{Z1?1 zTIJDlP?H)(iYd<4K34&QR8IzN?<}WdnUs8yo`YxDm)UuMzXXos_MIl~%)aYbsGtjl H^8on=COZ!c literal 0 HcmV?d00001 diff --git a/tests/pyc/comprehension_default_arguments.3.14.pyc b/tests/pyc/comprehension_default_arguments.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..869ec15964899bfd358faa955b586b86eec4358f GIT binary patch literal 2785 zcmcIm%}*Ow5P!St5Bv!zL4>vjVww;{VuxUfR8644#tEWI(Sj3NRYG7d#Er3+x4UV3 zvwCW*9;$|ebECO5mLk=;G^u(BD3=`LU?J*j|A1Dwm*mu$=MTKW8%nCK^nN?LJNw?7 z-~MK12i-2aKnVG>-*0=m_RSA}fL%Yhs#U%KMw*DElc+@G06CEdCn%ACl_{58@>B3y*m1Rv4(@Oy-;wof;{ z2i|yyypIIaLt+LB=_SIc|<07W!bu9-;{{FLQB{6JFVr zglfAvY zr6E;PRK>p(|4P;1nLnCZk*Rb;N-FVGay}+4Er9#{0$tWtq@=3&2Sey zdB@TI{czU&+~Ij{^1Lhy#C?S;;}qQFZ+LP|D^(iw|K_rmN1*P}3$j{$U_e$ZcZ&Ex{>!6(;$y}mK`jBZ~P z^Al?`*^WD(y>RywE{eCMTmTg?vtI$T9OE`~delGx-?8NkQm@aLw#L+hRE1Oonn7%k zf_yCnHD2c7>ckre1CO{aB@a+W5;9DR&12TkF4+3JFv_32PFd(m(KWdldOY(}|BwCm zPvysp-jOZ$`GR*O+mX@WRCJ%;3?XHcddhYM()z*6E!SijWtTa2iCFI0KXg8@g7KRf zK&QQW{d!vx8?}cdvb19)-uxd)v{y-lETbL>(+h*XgMe)6B6PqFmR;*$G|Mjb+0Piu ziJWWR8KZzxBVFsh5z<5G+?4odI&WmpeE^Y1fwSHQZ+dt)k%U5OGeSZ-lne(*w_yZ#ZpSK zzp;wVN)AgvrZI}_Qhb{&R|bPr{sLMqtfXRELK>yeaS8Te^Q?P7~L?>pl?v KMi<%-ul)lLSqnq} literal 0 HcmV?d00001 From 2c39f524bd87ce711f2d83daacab24101c37bd1c Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:32:26 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../b_comprehension_default_arguments.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/behavior/cases/b_comprehension_default_arguments.py diff --git a/tests/behavior/cases/b_comprehension_default_arguments.py b/tests/behavior/cases/b_comprehension_default_arguments.py new file mode 100644 index 00000000..2ff920a8 --- /dev/null +++ b/tests/behavior/cases/b_comprehension_default_arguments.py @@ -0,0 +1,27 @@ +# MIN_VERSION: 3.12 + + +def list_default(rows): + row = "saved-row" + field = "saved-field" + result = [field for row in rows for field in row.get("values", [])] + return result, row, field + + +def dict_default(rows): + row = "saved-row" + field = "saved-field" + result = {field: len(field) for row in rows for field in row.get("values", {})} + return result, row, field + + +def nested_list(rows): + row = "saved-row" + field = "saved-field" + result = [[field for field in row.get("values", [])] for row in rows] + return result, row, field + + +print(list_default([{}, {"values": ["a", "bb"]}, {}])) +print(dict_default([{}, {"values": {"a": 1, "bb": 2}}, {}])) +print(nested_list([{}, {"values": ["a"]}, {}]))