diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 6c90274a..26b59d71 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -153,6 +153,13 @@ inner `BUILD_MAP 0` accumulator lands on the walker's stack, `SWAP` is not modeled, and the following `FOR_ITER` treats it as a second `for` clause (`... for c, v in {}`), so the output does not parse. +Simple expanded calls in a Python 3.13 or 3.14 generator, such as +`(factory(*row) for row in rows)` and `(factory(**dict(row)) for row in rows)`, +now respect `CALL_FUNCTION_EX`'s callable/NULL order introduced in 3.13. In +3.14 they also consume its unconditional keyword slot and treat a NULL marker +as absent keyword arguments. The nested-comprehension stack issues below +remain. + A frame-stack prototype (one frame per comprehension level, `SWAP` modeled, the accumulator placeholder replaced by Rc identity when a level ends, `pending_or_filter` flushed per level) was implemented **twice** and diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..1bbdd3e8 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -52150,15 +52150,17 @@ fn genexpr_ternary_merge( } Op::CALL_FUNCTION_EX => { // `f(*t)` / `f(*a, **k)` element: [callable(+marker), - // args-iterable, (kwargs dict when arg&1)] + // args-iterable, kwargs dict (or NULL in 3.14 when absent)]. + // Before 3.14 the kwargs slot exists only when arg&1 is set. let underflow = || Rc::new(Expr::Name("?".to_string())); - let star_kw = if inst.arg & 1 != 0 { - stack.pop() + let star_kw = if self.version.at_least(3, 14) || inst.arg & 1 != 0 { + stack.pop().filter(|value| !is_null_marker(value)) } else { None }; let star = stack.pop().unwrap_or_else(underflow); - let func = if self.version.at_least(3, 14) { + // Since 3.13 the NULL marker follows the callable. + let func = if self.version.at_least(3, 13) { if matches!(stack.last(), Some(m) if is_null_marker(m)) { stack.pop(); } diff --git a/tests/behavior/cases/b_generator_expanded_calls.py b/tests/behavior/cases/b_generator_expanded_calls.py new file mode 100644 index 00000000..f2164184 --- /dev/null +++ b/tests/behavior/cases/b_generator_expanded_calls.py @@ -0,0 +1,20 @@ +# MIN_VERSION: 3.13 + + +def positional(rows, factory): + return tuple(factory(*row) for row in rows) + + +def keywords(rows, factory): + return tuple(factory(**dict(row)) for row in rows) + + +def both(rows, factory): + return tuple(factory(*row[0], **row[1]) for row in rows) + + +def factory(*args, **kwargs): + return args, sorted(kwargs.items()) +print(positional([[1, 2], [3]], factory)) +print(keywords([{"a": 1}, {"b": 2}], factory)) +print(both([([1], {"a": 2})], factory)) diff --git a/tests/fixtures/generator_expanded_calls.py b/tests/fixtures/generator_expanded_calls.py new file mode 100644 index 00000000..7f20ebc1 --- /dev/null +++ b/tests/fixtures/generator_expanded_calls.py @@ -0,0 +1,12 @@ +# CALL_FUNCTION_EX changes stack layout in CPython 3.13 and 3.14. + +def positional(rows, factory): + return tuple(factory(*row) for row in rows) + + +def keywords(rows, factory): + return tuple(factory(**dict(row)) for row in rows) + + +def both(rows, factory): + return tuple(factory(*row[0], **row[1]) for row in rows) diff --git a/tests/fixtures/generator_positional_call.py b/tests/fixtures/generator_positional_call.py new file mode 100644 index 00000000..e8f727a6 --- /dev/null +++ b/tests/fixtures/generator_positional_call.py @@ -0,0 +1,2 @@ +def select(rows, factory): + return tuple(factory(*row) for row in rows) diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..49816199 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -158,6 +158,25 @@ fn py313_hello() { assert_contains(&src, &["class Greeter:", "while a < n:", "a, b = b, a + b"], "py3.13 hello"); } +#[test] +fn generator_expanded_calls() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let fixture = format!("generator_expanded_calls.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "def positional(rows, factory):\n return tuple((factory(*row) for row in rows))", + "def keywords(rows, factory):\n return tuple((factory(*(), **{**dict(row)}) for row in rows))", + "def both(rows, factory):\n return tuple((factory(*row[0], **{**row[1]}) for row in rows))", + ], + &fixture, + ); + assert_eq!(src.matches("factory(").count(), 3, "{fixture}:\n{src}"); + } +} + #[test] fn py314_hello() { // 3.14 is brand new; accept incomplete-marker output as long as the diff --git a/tests/pyc/generator_expanded_calls.3.11.pyc b/tests/pyc/generator_expanded_calls.3.11.pyc new file mode 100644 index 00000000..fb86771d Binary files /dev/null and b/tests/pyc/generator_expanded_calls.3.11.pyc differ diff --git a/tests/pyc/generator_expanded_calls.3.12.pyc b/tests/pyc/generator_expanded_calls.3.12.pyc new file mode 100644 index 00000000..52f56319 Binary files /dev/null and b/tests/pyc/generator_expanded_calls.3.12.pyc differ diff --git a/tests/pyc/generator_expanded_calls.3.13.pyc b/tests/pyc/generator_expanded_calls.3.13.pyc new file mode 100644 index 00000000..2a963867 Binary files /dev/null and b/tests/pyc/generator_expanded_calls.3.13.pyc differ diff --git a/tests/pyc/generator_expanded_calls.3.14.pyc b/tests/pyc/generator_expanded_calls.3.14.pyc new file mode 100644 index 00000000..ac01da07 Binary files /dev/null and b/tests/pyc/generator_expanded_calls.3.14.pyc differ