From cb12d82682c66d605b1f0b38fb81d0d7b02ce723 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 16:10:49 -0400 Subject: [PATCH 1/2] Preserve ordered keyword expansions and dictionary boundaries --- src/codegen.rs | 2 + src/decompiler.rs | 50 ++++++++---------- tests/fixtures/ordered_keyword_expansions.py | 30 +++++++++++ tests/integration.rs | 23 ++++++++ tests/pyc/ordered_keyword_expansions.3.10.pyc | Bin 0 -> 1243 bytes tests/pyc/ordered_keyword_expansions.3.11.pyc | Bin 0 -> 1717 bytes tests/pyc/ordered_keyword_expansions.3.12.pyc | Bin 0 -> 1513 bytes tests/pyc/ordered_keyword_expansions.3.13.pyc | Bin 0 -> 1513 bytes tests/pyc/ordered_keyword_expansions.3.14.pyc | Bin 0 -> 1497 bytes tests/pyc/ordered_keyword_expansions.3.9.pyc | Bin 0 -> 1309 bytes 10 files changed, 77 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/ordered_keyword_expansions.py create mode 100644 tests/pyc/ordered_keyword_expansions.3.10.pyc create mode 100644 tests/pyc/ordered_keyword_expansions.3.11.pyc create mode 100644 tests/pyc/ordered_keyword_expansions.3.12.pyc create mode 100644 tests/pyc/ordered_keyword_expansions.3.13.pyc create mode 100644 tests/pyc/ordered_keyword_expansions.3.14.pyc create mode 100644 tests/pyc/ordered_keyword_expansions.3.9.pyc diff --git a/src/codegen.rs b/src/codegen.rs index 89be6894..7032b3ca 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -1134,6 +1134,8 @@ impl Printer { first = false; if let Some(kn) = k { self.write(&format!("{kn}=")); + } else { + self.write("**"); } self.expr(v, prec::TERNARY); } diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..3f05b93c 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -15280,8 +15280,8 @@ impl<'a> Ctx<'a> { let (ex_kws, star_kw) = if let Some(kw) = kwargs_opt { let (k, s) = flatten_ex_kwargs(kw.clone()); // a DICT_MERGE-built kwargs dict with const keys - // plus ONE trailing star is the compiler's own - // layout for `f(kw=..., **x)` — decompose it back + // plus expansion entries is the compiler's own + // layout for `f(kw=..., **x, **y)` — decompose it back // (the whole-dict rendering recompiles to // BUILD_MAP + DICT_UPDATE and breaks sig-exactness; // configparser 3.12 _get_conv). A DICT_UPDATE-built @@ -15295,34 +15295,24 @@ impl<'a> Ctx<'a> { break 'dc None; } if let Expr::Dict(entries) = &*kw { - if entries.len() >= 2 - && matches!( - &*entries[entries.len() - 1].0, - Expr::Starred(_) - ) - && entries[..entries.len() - 1].iter().all( - |(kk, _)| { - matches!(&**kk, Expr::Const(o) - if matches!(&**o, PyObject::Str(_))) - }, - ) - { + if entries.iter().any(|(key, _)| matches!(&**key, Expr::Starred(_))) { let mut kws = Vec::new(); - for (kk, v) in &entries[..entries.len() - 1] { - if let Expr::Const(o) = &**kk { - if let PyObject::Str(name) = &**o { - kws.push(( - Some(name.clone()), - v.clone(), - )); + let mut named = Vec::new(); + for (key, value) in entries { + match &**key { + Expr::Starred(inner) => kws.push((None, inner.clone())), + Expr::Const(object) => { + let PyObject::Str(name) = &**object else { break 'dc None; }; + if !is_kw_name_ok(name) || named.contains(name) { + break 'dc None; + } + named.push(name.clone()); + kws.push((Some(name.clone()), value.clone())); } + _ => break 'dc None, } } - if let Expr::Starred(inner) = - &*entries[entries.len() - 1].0 - { - break 'dc Some((kws, Some(inner.clone()))); - } + break 'dc Some((kws, None)); } } None @@ -47810,7 +47800,11 @@ impl<'a> Ctx<'a> { _ => Vec::new(), }; match &*other { - Expr::Dict(d) => entries.extend(d.iter().cloned()), + // A call expansion of a constructed dictionary keeps + // that boundary. Flattening it into outer DICT_MERGE + // entries would change its internal DICT_UPDATE's + // overwrite semantics into duplicate-key errors. + Expr::Dict(d) if inst.op == Op::DICT_UPDATE => entries.extend(d.iter().cloned()), o => entries.push(( Rc::new(Expr::Starred(Rc::new(o.clone()))), self.name_expr(""), @@ -51362,7 +51356,7 @@ fn genexpr_ternary_merge( _ => Vec::new(), }; match &*other { - Expr::Dict(d) => entries.extend(d.iter().cloned()), + Expr::Dict(d) if inst.op == Op::DICT_UPDATE => entries.extend(d.iter().cloned()), o => entries.push(( Rc::new(Expr::Starred(Rc::new(o.clone()))), Rc::new(Expr::Name(String::new())), diff --git a/tests/fixtures/ordered_keyword_expansions.py b/tests/fixtures/ordered_keyword_expansions.py new file mode 100644 index 00000000..306e8715 --- /dev/null +++ b/tests/fixtures/ordered_keyword_expansions.py @@ -0,0 +1,30 @@ +def direct(fn, first, second): + return fn(1, fixed=2, **first, **second) + + +def interleaved(fn, first, second): + return fn(1, **first, fixed=2, **second) + + +def mappings(fn, first, second): + return fn(1, **first, **second) + + +def dictionary(fn, first, second): + return fn(1, **{"fixed": 2, **first, **second}) + + +def non_identifier(fn, first, second): + return fn(1, **{"my col": 2, **first, **second}) + + +def reserved_word(fn, first, second): + return fn(1, **{"class": 2, **first, **second}) + + +def repeated_literal(fn, first, second): + return fn(1, **{"fixed": 2, "fixed": 3, **first, **second}) + + +def effectful(fn, get_first, get_second): + return fn(1, fixed=2, **get_first(), **get_second()) diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..983b4ce5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,26 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn keyword_expansions_keep_merge_checks_and_dictionary_boundaries() { + for version in ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] { + let fixture = format!("ordered_keyword_expansions.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "return fn(1, fixed=2, **first, **second)", + "return fn(1, **first, **{'fixed': 2}, **second)", + "return fn(1, **first, **second)", + "return fn(1, **{'fixed': 2, **first, **second})", + "return fn(1, **{'my col': 2, **first, **second})", + "return fn(1, **{'class': 2, **first, **second})", + "return fn(1, **{'fixed': 2, 'fixed': 3, **first, **second})", + "return fn(1, fixed=2, **get_first(), **get_second())", + ], + &fixture, + ); + } +} diff --git a/tests/pyc/ordered_keyword_expansions.3.10.pyc b/tests/pyc/ordered_keyword_expansions.3.10.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54ae9dcc555199d7c4bd4da627f88c63250704e7 GIT binary patch literal 1243 zcma)5J#Q015Z%2mpFiV75JVIt+LI6w5(Nc9NKhg->D*YSn{~9}d}qy`NsQb2zKOpiw0SqZy8WD6vCkCu^Vn zv{@gK!&z&=8CSV)GDi+nCDT}YHn%ptmTjFC3R4w-P{0wd8S+SCm&^|2x=;snN{~(k zSr;jC2eu-}f&L8{PU7iQXJ@8K*DS>YiF-HU)fBHc6l-a&NSZ66%i`QYu?0FQsJb!E znHBbU+rJGD`*a%l6`PO2Wd1nGQ)KpcQ0rxRHqxN7LXWkwAQc=JY^R(xy)C%XPtw?! z|3T|gGbtqDM%+gCIX%nm-c4XExowwYa!5O0+c5&~o3oIARx=8ItH^v;JRgyRY^v>c zZTqOEDlRCcR8wEY=|lR^YJ4n_$7`nu>z=ALt`+_yxQTh+);Mx8q(H;wnJPwh}^SCb{x#-0P5)u+Z;=xPPbf#ou+1A+w!Svw4 zgYjT`qb3|UfR^aN|G|G?y_sG;aUe_o^(BT4Eww_CEc-#5>iQ#e<3$tLC(vKYQb^sisOz}H(N1b zD_QhZKX%g(-8+Cc?5CA@H||>_81}+ z6Xz7IQ*nY`K!>?XKxtXF<4IMPg>@;^04x+ScNoykY9ipW!`t5zZt4;icX11Yeibuq zZbrbZ6Yz9oQAbzxV99yh;;V&f)pk}~HHcN3`z;6|v6OG6>L8YC%mHYask3a(iBu;E z_K*a7h#IBAg^}tLcG1H<7SyJ?0vZ&)qkDhy$@o^L{-m+w5038)0h*Hl*l3uUe`1!7 z2Jw0wTir|+D`iDqT^PK6r{d&oAsx>y*;1)%*g*H|c2UmyR<{1MQSb*dI}?EB8~`>N zX7;~OZbggbg6saBaRTKd74k4og`;kQEX7$-7M*26iK7c3389^2QJc}V3Uk}2@jov% z9c!l>h3$a0r42=C8o)(Kcv;D+EO=m5woxgCvZ9K+j@P{p2$ONvw`Lnl%>jRK2BRqf z-_}X7XvQ~Z&$5ljw!eNASerRz1;E;t7 l4<&7bdY5L~05wZ$8=y{*+J>{Uk2p<-hy@SHa2m9OzX4m~Y1;q* literal 0 HcmV?d00001 diff --git a/tests/pyc/ordered_keyword_expansions.3.12.pyc b/tests/pyc/ordered_keyword_expansions.3.12.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aaa035b731c9511c573bb93d7f035fc9006d82b0 GIT binary patch literal 1513 zcmbtUOKTHR7@a$rq)CRTB`7{ngV9AGwqO#CQW`f_MFoK_<&)>#93xc3pYCn@1Z;>!Jg5=DW{1-}xTDB9X99)3nXpr{9k@ z_X2-lPaZb125A^u8rQyPZ#B+o z>wqmy*BTo4aolCG2HR#0eVct{ueGj`@B7QvhT!`;Hg?%Q5$S4OUtcfVv?Lw3A8B!N@p7zcL;!QFv06y7uC2y?6CB1lD9 zm-KtmXmI<8N2>X}wd_S7wC@2r4*@+hQ?Qu0ilF4$GOU~yr) zR@o9#Uc~{5n{$k1y7{72^rESD2GCgo^vtwpq|eK;?=Mw~j`M$#5mbPXNT~wlzPtf` zq^6|wI7wm#vnrQkFh<#9Sg3T43jd)u*zxcs{CV~NeL9iQrGd{7&dRP!Qq)Az1)++C zd@oic)y*}$$92oKmOHb(=we5B(Z!$ZfS#H2jND1elYE=Q731<7W*gcH+r=zB+YBT9 zXf&OF37-sOQ$MsK+`_P{gvp%lEWQ1uyA-m1D(YOBmqp;20>Ylp>2<*ojD7+hGb^0kUslyS_dMnw+) d^)bc{bjI!<1V?a}tTaJpVm)O8j2|l!{{UmYuW6n$?PC@`Vg5RKNTSn7fV+eV;4)3h$ysxcvHCb%UN=0PS-2fQ<_#g#vS zO%se8mJm1o2!Ft^bhfU#GiBw@dmkT#G_8xiWX|08eeSvUzF{H}4>87`czyW0`?LNV z?P=p6 zS(&XcJ*4AV8@$YSd0E-zn|zh^)lv|yStr6gQ*dFQpAnHh>xTlR=Fk+lUJ!9bwc(g9 z9hgd`f~+T8&t1oT-Vq3Qy#b~GVOBnf)+e+p;-qOfuGMhdwPq`bm==ht2aC9yPIrmY z7xqg_476;>!jjKSmy1_ye>Kq69I731diL zC(ta%6JQJDSeUScBP7y1n*ws^a)L1%xP@!x*i4nZi!)<3e8gs=YDOsmk-myZ^w4NF zEvN265?4v0chDGXl5Q7#E#284XlY+fUnX#c9FySgVQ}|Q8Vc{Va>Pxm>S2%!XaQYM z8Vw&`@W{1acMkmI^X^lG-X_98%lT^VMvO9Q>2u_;m8v#uASJKWY0+so70VQkXVokL za1S?7+}s(K`SzQR;U{z50zz*eVW8!GHGflWPKcgKP1yuZv;JWO(4WcOw4++^8=DT47 z)y;kUW_8=?9Q2k3$?cx-liNRz5eC|hukKu?Jk7T?JYrIQlWbdI1stVDML64=&%aCv z;fgc5Z$$%ubMM`M z{jIF5ggKuHU*zEvf1fjvZ*ZG&W>?>`)_bt4?67Hh!fAM7wb=^ew!>M?=V|yF?-CKC z?|OQm=i2WAJ=b{((5=8S&~igKLMB3q!-fpy78v7D@&v<(0y+za<2jx{NCJ!*^GmP@ zb;t7=cbRp-Y(5J+n4?KHCE(Dd2qQK;17{}KoF?`v{4-)hBR1otW)+!6j>P{&Cb?%d zn~t|D_+>bif+-fHLzx$&T?%xgvop|*Kr=3q_(Mv@_1%X0ZbN#o!c$W+V>>k;3T5#Y zzXI1|jfS_+cof>tIWR(qU?s`PJwVnL%^%A&J^D z#p-o$lk0m8d*5Xnd>QuSyMzJ%R4Jt=3Z)NDk|Vf_RuQ3eYCWO9VEssv F_zjmz8SwxB literal 0 HcmV?d00001 diff --git a/tests/pyc/ordered_keyword_expansions.3.9.pyc b/tests/pyc/ordered_keyword_expansions.3.9.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3905ae4d3d1020be99b162b57036adf65c79463 GIT binary patch literal 1309 zcmb_cOK;Oa5Z?7GuO@AINg$B8T0-;>AOr%gx%8GxWO>%r3dgo)9cWN)<%YyB;DXdj z~wUFebbpbrD`KJ3Aed;lZZC*OgCV-X(4oUnNqd$eBA zGIz)<1x2O!S}x_eTsr6SqdXQXf4r8oTltATRj_7Sp+t5ov2rUX$#@G*Sp`PLc?Rgy zo;5!nmCBUnh)$MsROTp0P^e(~S}ooZo~qL#%8br4^SD@40cccl$=M9Y7ZL|#w^(z0 z%laMxW~~J?uDM?_M-Ehz2{y=b>(gsB&{?T4RnZ#-{B4tRkMLH?O`&ekE~RwplueOB zcWf*2t1y2~+MPv3p|cayYh@f>m+_iQHqE)9=3LN)xjj2B*#{k$RNn{}oTa(r=pQnM zetp+>!EEs`&Qs*t?7a9W%d@Elm6bZt3J+Uk_WuK!%8%2?n7`vPq)}8zZJ+WG-fmH0 zT3fy6;j!a(p8)mV*H(t$eR7tv-HR}&D`Q!#Szn=p?5x>Yq61Wgib|rDYMRU_y-P=% zj@=}@#cVT`jS!j%ITI`cwpV8)bg@H$CcP6?POWGseTKga7p+2iDoIF>$vl0~VseY{ uIP6+!B8vrivXDm#xs|rJ$b6B1diifqBOAH Date: Tue, 13 Oct 2026 18:34:58 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_ordered_keyword_expansions.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/behavior/cases/b_ordered_keyword_expansions.py diff --git a/tests/behavior/cases/b_ordered_keyword_expansions.py b/tests/behavior/cases/b_ordered_keyword_expansions.py new file mode 100644 index 00000000..1d45533c --- /dev/null +++ b/tests/behavior/cases/b_ordered_keyword_expansions.py @@ -0,0 +1,32 @@ +# MIN_VERSION: 3.9 + + +def direct(fn, first, second): + return fn(1, fixed=2, **first, **second) + + +def dictionary(fn, first, second): + return fn(1, **{"fixed": 2, **first, **second}) + + +def effectful(fn, get_first, get_second): + return fn(1, fixed=2, **get_first(), **get_second()) + + +def fn(*args, **kwargs): + print("call", args, sorted(kwargs.items())) +def first(): + print("first") + return {"fixed": 7} +def second(): + print("second") + return {"extra": 8} +try: + direct(fn, {"fixed": 7}, {"extra": 8}) +except TypeError: + print("duplicate") +dictionary(fn, {"fixed": 7}, {"extra": 8}) +try: + effectful(fn, first, second) +except TypeError: + print("duplicate before second")