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/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") 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 00000000..54ae9dcc Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.10.pyc differ diff --git a/tests/pyc/ordered_keyword_expansions.3.11.pyc b/tests/pyc/ordered_keyword_expansions.3.11.pyc new file mode 100644 index 00000000..0a33d062 Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.11.pyc differ 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 00000000..aaa035b7 Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.12.pyc differ diff --git a/tests/pyc/ordered_keyword_expansions.3.13.pyc b/tests/pyc/ordered_keyword_expansions.3.13.pyc new file mode 100644 index 00000000..444fa357 Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.13.pyc differ diff --git a/tests/pyc/ordered_keyword_expansions.3.14.pyc b/tests/pyc/ordered_keyword_expansions.3.14.pyc new file mode 100644 index 00000000..02f4bed8 Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.14.pyc differ 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 00000000..b3905ae4 Binary files /dev/null and b/tests/pyc/ordered_keyword_expansions.3.9.pyc differ