From cd4357e3a7556a11fae24d3d392f25a2f0b6132b Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:03:14 -0400 Subject: [PATCH 1/5] Handle expanded generator call slots on Python 3.13 and 3.14 --- .../workflows/generator_expanded_calls.yml | 24 +++++++ docs/LIMITATIONS.md | 7 ++ src/decompiler.rs | 10 +-- tests/test_generator_expanded_calls.py | 71 +++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/generator_expanded_calls.yml create mode 100644 tests/test_generator_expanded_calls.py diff --git a/.github/workflows/generator_expanded_calls.yml b/.github/workflows/generator_expanded_calls.yml new file mode 100644 index 00000000..184adc71 --- /dev/null +++ b/.github/workflows/generator_expanded_calls.yml @@ -0,0 +1,24 @@ +name: Generator Expanded Calls + +on: + pull_request: + push: + branches: [fix/generator-expanded-call-slot] + +permissions: + contents: read + +jobs: + roundtrip: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: cargo build --locked + - run: python tests/test_generator_expanded_calls.py 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/test_generator_expanded_calls.py b/tests/test_generator_expanded_calls.py new file mode 100644 index 00000000..4b357e22 --- /dev/null +++ b/tests/test_generator_expanded_calls.py @@ -0,0 +1,71 @@ +"""Round-trip expanded generator calls across Python 3.11–3.14 stack layouts.""" + +import importlib.util +import marshal +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +BINARY = Path(os.environ.get("PYCDC_BINARY", ROOT / "target/debug/pycdc")) + + +class RoundTrip(unittest.TestCase): + def recover(self, source): + code = compile(source, "fixture.py", "exec", dont_inherit=True) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "fixture.pyc" + path.write_bytes( + importlib.util.MAGIC_NUMBER + bytes(12) + marshal.dumps(code) + ) + result = subprocess.run( + [str(BINARY), str(path)], capture_output=True, text=True, timeout=10 + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("incomplete", result.stderr.lower()) + self.assertNotIn("INCOMPLETE", result.stdout) + original, recovered = {}, {} + exec(code, original) + exec( + compile(result.stdout, "recovered.py", "exec", dont_inherit=True), recovered + ) + return original["select"], recovered["select"] + + def test_expanded_calls_keep_arguments_and_error_order(self): + cases = ( + ("factory(*row)", [(1, 2), (3,)]), + ("factory(**dict(row))", [{"a": 1}, {"b": 2}]), + ("factory(*row[0], **row[1])", [((1, 2), {"flag": 7}), ((3,), {})]), + ) + for expression, rows in cases: + source = ( + "def select(rows, factory):\n" + f" return tuple({expression} for row in rows)\n" + ) + for failure_at in (None, 1, 2): + with self.subTest(expression=expression, failure_at=failure_at): + original, recovered = self.recover(source) + outcomes = [] + for function in (original, recovered): + events = [] + + def factory(*args, **kwargs): + events.append((args, tuple(kwargs.items()))) + if len(events) == failure_at: + raise ValueError("factory failed") + return args, kwargs + + try: + value = ("value", function(rows, factory)) + except ValueError as error: + value = (type(error).__name__, str(error)) + outcomes.append((value, events)) + self.assertEqual(outcomes[1], outcomes[0]) + self.assertEqual(len(outcomes[1][1]), failure_at or 2) + self.assertEqual(recovered([], factory), ()) + + +if __name__ == "__main__": + unittest.main() From c0d890d4af2f0d4e9f0fbcae7b97b6c2da27e7ab Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:19:59 -0400 Subject: [PATCH 2/5] Run version-gated Python regressions in the shared CI matrix --- .github/workflows/ci.yml | 17 +++++++++++++ .../workflows/generator_expanded_calls.yml | 24 ------------------- tests/test_generator_expanded_calls.py | 5 ++++ 3 files changed, 22 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/generator_expanded_calls.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ff9e11..240ed91a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,23 @@ jobs: "$das" tests/corpus/3.12.14/base64.pyc | head -3 cat tests/corpus/3.12.14/base64.pyc | "$bin" - | head -3 + python-regressions: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14", "3.15"] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: cargo build --locked + - run: python -m unittest discover -s tests -p 'test_*.py' -v + clippy: # informational: the codebase carries a style-warning long tail # (see docs/LIMITATIONS.md); this job never blocks diff --git a/.github/workflows/generator_expanded_calls.yml b/.github/workflows/generator_expanded_calls.yml deleted file mode 100644 index 184adc71..00000000 --- a/.github/workflows/generator_expanded_calls.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Generator Expanded Calls - -on: - pull_request: - push: - branches: [fix/generator-expanded-call-slot] - -permissions: - contents: read - -jobs: - roundtrip: - runs-on: ubuntu-latest - strategy: - matrix: - python: ["3.11", "3.12", "3.13", "3.14"] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - run: cargo build --locked - - run: python tests/test_generator_expanded_calls.py diff --git a/tests/test_generator_expanded_calls.py b/tests/test_generator_expanded_calls.py index 4b357e22..1adcf2d8 100644 --- a/tests/test_generator_expanded_calls.py +++ b/tests/test_generator_expanded_calls.py @@ -5,6 +5,7 @@ import os from pathlib import Path import subprocess +import sys import tempfile import unittest @@ -12,6 +13,10 @@ BINARY = Path(os.environ.get("PYCDC_BINARY", ROOT / "target/debug/pycdc")) +@unittest.skipUnless( + sys.implementation.name == "cpython" and (3, 11) <= sys.version_info[:2] < (3, 15), + "requires CPython 3.11–3.14; 3.15 LOAD_COMMON_CONSTANT 7 (None) is not handled", +) class RoundTrip(unittest.TestCase): def recover(self, source): code = compile(source, "fixture.py", "exec", dont_inherit=True) From 7d80cf5081044077478aab024876b74ea4bf05c7 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:37:19 -0400 Subject: [PATCH 3/5] Use committed Python fixtures in the existing integration suite --- .github/workflows/ci.yml | 17 ----- tests/fixtures/generator_expanded_calls.py | 12 ++++ tests/integration.rs | 19 +++++ tests/pyc/generator_expanded_calls.3.11.pyc | Bin 0 -> 1421 bytes tests/pyc/generator_expanded_calls.3.12.pyc | Bin 0 -> 1222 bytes tests/pyc/generator_expanded_calls.3.13.pyc | Bin 0 -> 1246 bytes tests/pyc/generator_expanded_calls.3.14.pyc | Bin 0 -> 1483 bytes tests/test_generator_expanded_calls.py | 76 -------------------- 8 files changed, 31 insertions(+), 93 deletions(-) create mode 100644 tests/fixtures/generator_expanded_calls.py create mode 100644 tests/pyc/generator_expanded_calls.3.11.pyc create mode 100644 tests/pyc/generator_expanded_calls.3.12.pyc create mode 100644 tests/pyc/generator_expanded_calls.3.13.pyc create mode 100644 tests/pyc/generator_expanded_calls.3.14.pyc delete mode 100644 tests/test_generator_expanded_calls.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 240ed91a..96ff9e11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,23 +38,6 @@ jobs: "$das" tests/corpus/3.12.14/base64.pyc | head -3 cat tests/corpus/3.12.14/base64.pyc | "$bin" - | head -3 - python-regressions: - name: Python ${{ matrix.python-version }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.12", "3.13", "3.14", "3.15"] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - run: cargo build --locked - - run: python -m unittest discover -s tests -p 'test_*.py' -v - clippy: # informational: the codebase carries a style-warning long tail # (see docs/LIMITATIONS.md); this job never blocks 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/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 0000000000000000000000000000000000000000..fb86771dd7cfd8e5fc05e41c452d4f1ada06f452 GIT binary patch literal 1421 zcmcgs-%Aux6h3EW%ylPY1i>F6NS3e#JA$!cOcE8n1WOPE6=B=nv6ePxxijJqHt->+ zAVh-Dz`zGF7hn7z`UghXr-7b&(Kl&d#HY@gac4)d9=+W8_MAEAe)rD#?%5xmooN*S zo_+cGXYt9yq2FZ31COjmFB!`q;5|GAfrKa&LPe;LAfpwPcm;q)HWM~(5`4gA@R}^- zf5b_o^{Fb{gAMYx=!wP#Wo^Zo#H!?ru}djTJ4#RE62Vt|07wU_!t;)IsK6(sii_|V zmJ#7|S^zD_d<|-9pkzk^RXQ_4`(&Z$IdabBvkeX|9A5Qo*K^Iu(yTWvZP$EdPua3S zDHHZ=r7$IIF;Og(%Wk$Zci3TZbhaYL=Laf|Tk=ZIRH2-;$_}Nt)H6_sKw607s5tnuFh3uf8Al=$m8uWrc z!yP6#Sb$%;k)+(bOx})R)(Lg9uA9DQN}W>ALMJ|WsM8%X5<2xoIteTtyAV}8F8n$! z;*pO1p^i#ka#1rFXrff~WE(k64a3UFG`*R>_{N@_ainlh5hbkU7-hwxL%h0hhra+{2VEWOS45zt4GXq2EzUE1;!qVx%cnoHf|#69lMNS=Sm((}^sUY{Zf zm(t}wh*%QKH>_XVhi>agE+ZSC$RuHPqvr@;xy`9DoG-d6Gl@U=gtM~h?IBf1^K zsuSu~UAKJA3Q6-BHEgj^(2ym(g3*TDjE5=fts|z(_PFdPm&@Q<1p6u??w>;(*@qZX Qh&R|h0&;J$dlEwSzeZ{@DF6Tf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..52f5631926ba98d0525d008a4964618667e8c92f GIT binary patch literal 1222 zcmcJOL2DCH5XWbBw>E7o-IQw7&{C)sV?^ zDoUZCrlN-k9ukU>@8Kme?Pc-i#onZ$Px78uB5Y_P*O#qY5KO#lX-`?0jYr^-0K5)iVD0^ins#rv4jY3!WtCd ztGa-3Ojn_*`buKTSDCxu_s(Xrp34ec^fWfMym!H~xyOy!+@d$nY;HWX=WLdtkQsY1 zpP934YbKj<9G=J*_WDerF6LQkX(aFRoR@RwGETyDTngc-L`N3A@)Klsf5GlDeAkWg z^lEf%{Ow3XH~w%TKN}pvD*8R%eBQCEimy?3oP|W9z&CcE(Zj&+|91}?qyy15A0|U( ze}wg>ZdA^0=teVa)HS0;=WsOiMPriwb#l7s(>M+WNmmuPd3BJx|U=*sYe4>+$-T;C8rDSPyn}x z7Mvz62jrpfy08utRz)tmxH~fNIuMvA1iP59jKU z$?}Z~e!jICnQZ808||ssVQ4jR2NS&t^#5TsYu<)#Hp6CJGbPq^T$S4`ryz6Wen?I3 l!(PaI;#%{LonmLGns}kC_*+H9?PG{j+abiuEi%_+)?Wm1{~-VX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2a96386734b92865db1902b084f7d29ca66da482 GIT binary patch literal 1246 zcmcJO&rcIk5XWbB7b#TCR*D$0q}pOqNxDsdB_?ZQq9I=FrS@bZ)^@>a?KbZ%0ym6* z0f#0SJ>ZRUlKvZBYHBdqcrl*18G6Bsao+2;wh0{Bq|@1*A8+>aoo$bejj90f_UD&K zZu|S+U*z{L^u6{Ka%zx;Yrr6jWHGCdRaVu6R#1BR02=wCT{}tUDc0agw+G~K9!2fT zKn})X!7p0y<8eqRnL;m_qN3Ft{bZ{&#vn=~h|-6n02O?!+@zK!yn)(K6dqj!2vWu> zEUGJ*z)T(Ls;4AVp30ncZ+InNa2>YE#ca{&mUr8=xyy}}(uTXnY;LUD6`SQLW!c`S z<|{?JxLnAW%RE`#^hPWZ-Kes(7pXeD46xVhsm65 z!H=N+O*iWCmTt6yMpHA|^c>Dh1u>dbVTqgp6=)|qDr7M=AahsJ2WX~G6$WK8niA5;OM{PF3d$H}wuc4Wb^SkPYJBOi} zM%YI_eFGV-2e3=xoPNxK?g>mR_^=4~LpBe>E!}Jd&8B9u5Jk@mvN%~nzDLdgB@g;*13u9xIU^_Ts@)Z_@NoZ@$@V)+o)r3we37?{nt&W~T@G`(yy9 zel0G1IeGozC+Yr!p4PZbN(HhI1sk$R60;IXDJv@w59B330w6%TNZ42-p<@M>eB*6= zAT2;43NQlWkboS{qR43ma}b6(&o52U8ELT#pclp<*{#4t2hTaLKk5PVtzJbIiiKJF zHD*X?c#W!6;1Q%l0401ViF^~(Xc;P9l5!E_n5jWccBI6NBQtB$=~>nD zw#Bx%*jDUn#kp=9+~(?Pv21TJgR4)Bb%W{TvSO4=`g*}AtmJjm~?fVc{BQH@;4Xe=NE^thEA8g zQ8JC1X05Rao+8Acw8lxQjk#8e%a~K4&Sz#Hq&72^5NXn~AAu zF?LUq_0bz=_^GkAX|V!71DbpGqvYj5OYIHrD%;U-k*RlcRr&3$!^qVBpa*Yk0@(#} zWdov4JFsA@0;BCfk1+p>i_+G^DS;~`a5b0pP>R+pLhk1dm)L`g>pm%MxQ1J}a<~jP zu8`4tRJxMO*#4#s938L=4!sL19f_zWy-w7kk1c!c3}S)+S4eoov_1Q?EUwv({yugP znXca3#gFO3$n?JLaZ_TQxCKNFMtcJKu>8-A64%3;V5SLX&1F5L(b{E}iOcTobeX$J r+-Y=e&3hcwo>_$r)0k%?lwQm>A0+WhM#ST8#F^tD;>{BhcigBy_OlWS literal 0 HcmV?d00001 diff --git a/tests/test_generator_expanded_calls.py b/tests/test_generator_expanded_calls.py deleted file mode 100644 index 1adcf2d8..00000000 --- a/tests/test_generator_expanded_calls.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Round-trip expanded generator calls across Python 3.11–3.14 stack layouts.""" - -import importlib.util -import marshal -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest - -ROOT = Path(__file__).resolve().parents[1] -BINARY = Path(os.environ.get("PYCDC_BINARY", ROOT / "target/debug/pycdc")) - - -@unittest.skipUnless( - sys.implementation.name == "cpython" and (3, 11) <= sys.version_info[:2] < (3, 15), - "requires CPython 3.11–3.14; 3.15 LOAD_COMMON_CONSTANT 7 (None) is not handled", -) -class RoundTrip(unittest.TestCase): - def recover(self, source): - code = compile(source, "fixture.py", "exec", dont_inherit=True) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "fixture.pyc" - path.write_bytes( - importlib.util.MAGIC_NUMBER + bytes(12) + marshal.dumps(code) - ) - result = subprocess.run( - [str(BINARY), str(path)], capture_output=True, text=True, timeout=10 - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn("incomplete", result.stderr.lower()) - self.assertNotIn("INCOMPLETE", result.stdout) - original, recovered = {}, {} - exec(code, original) - exec( - compile(result.stdout, "recovered.py", "exec", dont_inherit=True), recovered - ) - return original["select"], recovered["select"] - - def test_expanded_calls_keep_arguments_and_error_order(self): - cases = ( - ("factory(*row)", [(1, 2), (3,)]), - ("factory(**dict(row))", [{"a": 1}, {"b": 2}]), - ("factory(*row[0], **row[1])", [((1, 2), {"flag": 7}), ((3,), {})]), - ) - for expression, rows in cases: - source = ( - "def select(rows, factory):\n" - f" return tuple({expression} for row in rows)\n" - ) - for failure_at in (None, 1, 2): - with self.subTest(expression=expression, failure_at=failure_at): - original, recovered = self.recover(source) - outcomes = [] - for function in (original, recovered): - events = [] - - def factory(*args, **kwargs): - events.append((args, tuple(kwargs.items()))) - if len(events) == failure_at: - raise ValueError("factory failed") - return args, kwargs - - try: - value = ("value", function(rows, factory)) - except ValueError as error: - value = (type(error).__name__, str(error)) - outcomes.append((value, events)) - self.assertEqual(outcomes[1], outcomes[0]) - self.assertEqual(len(outcomes[1][1]), failure_at or 2) - self.assertEqual(recovered([], factory), ()) - - -if __name__ == "__main__": - unittest.main() From 831540978d84633750d47a8e26b7812884ee0de0 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:31:57 -0400 Subject: [PATCH 4/5] Add harness coverage for the reconstruction fix --- .../cases/b_generator_expanded_calls.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/behavior/cases/b_generator_expanded_calls.py 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)) From 34df3c9d8e0593735c85766c3564ce3362cf9c73 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:33:08 -0400 Subject: [PATCH 5/5] Isolate expanded generator calls for strict round-trip verification --- tests/fixtures/generator_positional_call.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/fixtures/generator_positional_call.py 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)