From 139a8259402571f2e9ff0891663cf5635b4455cf Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:03:14 -0400 Subject: [PATCH 1/4] Preserve separate evaluations when grouping assignment stores --- .github/workflows/assignment_identity.yml | 24 ++++++++ src/decompiler.rs | 5 +- tests/test_assignment_identity.py | 74 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/assignment_identity.yml create mode 100644 tests/test_assignment_identity.py diff --git a/.github/workflows/assignment_identity.yml b/.github/workflows/assignment_identity.yml new file mode 100644 index 00000000..3c5d0f13 --- /dev/null +++ b/.github/workflows/assignment_identity.yml @@ -0,0 +1,24 @@ +name: Assignment Identity + +on: + pull_request: + push: + branches: [fix/assignment-evaluation-identity] + +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_assignment_identity.py diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..4164c23d 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -21174,7 +21174,10 @@ let reopen = self let group = std::mem::take(&mut self.pending_stores); self.last_store_line = None; self.pending_unpack_wrap.clear(); - let all_same = group.windows(2).all(|w| expr_eq(&w[0].1, &w[1].1)); + // COPY/DUP preserves the identity of one evaluated stack value. + // Structurally equal expressions can be independent evaluations: + // `left, right = read(), read()` must still call read twice. + let all_same = group.windows(2).all(|w| Rc::ptr_eq(&w[0].1, &w[1].1)); if all_same { let value = group[0].1.clone(); let targets: Vec = group.iter().map(|(t, _)| t.clone()).collect(); diff --git a/tests/test_assignment_identity.py b/tests/test_assignment_identity.py new file mode 100644 index 00000000..b6d29878 --- /dev/null +++ b/tests/test_assignment_identity.py @@ -0,0 +1,74 @@ +"""Compile, decompile and execute original fixtures with CPython 3.11–3.14.""" + +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_independent_equal_assignments_keep_both_evaluations_and_true_chains(self): + sources = ( + "def select(read):\n left, right = read(), read()\n return left, right\n", + "def select(read):\n left = right = read()\n return left, right\n", + "def select(read):\n left, right = None, None\n return left, right\n", + "def select(read):\n right = left = None\n return left, right\n", + ) + for index, source in enumerate(sources): + original, recovered = self.recover(source) + self.assertEqual(original.__code__.co_code, recovered.__code__.co_code) + self.assertEqual( + original.__code__.co_exceptiontable, + recovered.__code__.co_exceptiontable, + ) + for failure_at in (None, 1, 2): + outcomes = [] + for function in (original, recovered): + events = [] + + def read(): + events.append(len(events) + 1) + if events[-1] == failure_at: + raise ValueError("owned evaluation") + return events[-1] + + try: + result = ("value", function(read)) + except ValueError as error: + result = (type(error).__name__, str(error)) + outcomes.append((result, events)) + with self.subTest(source=index, failure_at=failure_at): + self.assertEqual(outcomes[1], outcomes[0]) + if index == 0: + counter = iter((7, 8)) + self.assertEqual(recovered(lambda: next(counter)), (7, 8)) + + +if __name__ == "__main__": + unittest.main() From 0d295953fc4b20dba443d0924d9a253b51f0b261 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:19:59 -0400 Subject: [PATCH 2/4] Run version-gated Python regressions in the shared CI matrix --- .github/workflows/assignment_identity.yml | 24 ----------------------- .github/workflows/ci.yml | 17 ++++++++++++++++ tests/test_assignment_identity.py | 5 +++++ 3 files changed, 22 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/assignment_identity.yml diff --git a/.github/workflows/assignment_identity.yml b/.github/workflows/assignment_identity.yml deleted file mode 100644 index 3c5d0f13..00000000 --- a/.github/workflows/assignment_identity.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Assignment Identity - -on: - pull_request: - push: - branches: [fix/assignment-evaluation-identity] - -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_assignment_identity.py 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/tests/test_assignment_identity.py b/tests/test_assignment_identity.py index b6d29878..b345425e 100644 --- a/tests/test_assignment_identity.py +++ b/tests/test_assignment_identity.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 f2d2dc44a9b89ba59b1311715930d8f12340b796 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 14:37:19 -0400 Subject: [PATCH 3/4] Use committed Python fixtures in the existing integration suite --- .github/workflows/ci.yml | 17 ------ tests/fixtures/assignment_identity.py | 20 +++++++ tests/integration.rs | 20 +++++++ tests/pyc/assignment_identity.3.11.pyc | Bin 0 -> 839 bytes tests/pyc/assignment_identity.3.12.pyc | Bin 0 -> 757 bytes tests/pyc/assignment_identity.3.13.pyc | Bin 0 -> 725 bytes tests/pyc/assignment_identity.3.14.pyc | Bin 0 -> 723 bytes tests/test_assignment_identity.py | 79 ------------------------- 8 files changed, 40 insertions(+), 96 deletions(-) create mode 100644 tests/fixtures/assignment_identity.py create mode 100644 tests/pyc/assignment_identity.3.11.pyc create mode 100644 tests/pyc/assignment_identity.3.12.pyc create mode 100644 tests/pyc/assignment_identity.3.13.pyc create mode 100644 tests/pyc/assignment_identity.3.14.pyc delete mode 100644 tests/test_assignment_identity.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/assignment_identity.py b/tests/fixtures/assignment_identity.py new file mode 100644 index 00000000..d121ab02 --- /dev/null +++ b/tests/fixtures/assignment_identity.py @@ -0,0 +1,20 @@ +# Separate evaluations must not become one COPY-backed chained assignment. + +def separate(read): + left, right = read(), read() + return left, right + + +def shared(read): + left = right = read() + return left, right + + +def separate_none(): + left, right = None, None + return left, right + + +def shared_none(): + right = left = None + return left, right diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..ece00650 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -106,6 +106,26 @@ fn py310_basics() { assert_contains(&src, &["while i < 10:", "v = 1 < x < 100"], "py3.10 basics"); } +#[test] +fn assignment_identity() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let fixture = format!("assignment_identity.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "def separate(read):\n left, right = read(), read()\n return left, right", + "def shared(read):\n left = right = read()\n return left, right", + "def separate_none():\n left, right = None, None\n return left, right", + "def shared_none():\n right = left = None\n return left, right", + ], + &fixture, + ); + assert_eq!(src.matches("read()").count(), 3, "{fixture}:\n{src}"); + } +} + #[test] fn py311_comprehensions() { let (src, clean) = decompile_fixture("comprehensions.3.11.pyc"); diff --git a/tests/pyc/assignment_identity.3.11.pyc b/tests/pyc/assignment_identity.3.11.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3f73a5a9594a53bef37070321dbf34ea6a2d154 GIT binary patch literal 839 zcma))!D|yi6vp4oZfZ;j#cC4@MOH}yxwIg)7sXS0v5-q|B9x^&Nf+9*%xs~BEJW~- zV+w^-@FrNI=l&l#mc9B91aj-i@6DQRMG=Rck2kaL&HmmudsQkG93r~@)@g3M9ew$M zI2+-Mj+u@>cVZcf zp6JQ8+@Y~(jcM#8u2z1KxCg2oB{>~-_9MfmdY*S%i}x+#x`0A1HqRh^x>+g@tLOEL`epU9`lVOx5R;BPIRide*Q-h$>_?Xu(`b8h_*i)6v<`&tFMghig;u0p`{xH8;0 z-@MqIxGVkU;N=~TE+SzMGaDJs6Kvsb#Cz<-1P`ev*u#cw;b~cfhSU#`rtP=5((_V~ z(%hci?on6iMZ97rvkYO6RzGY5r1oDROQ_+W1U@hZKp5lB2SD)mEs41gQB2)P5gs9h zPcmhQwPfN}`dfp0cVo?HHC%h8)nqSZr|#q&cg*#^WljgfwC!2eP;mU5HU;Rxpr-6)aK; z7IIu|5iI>PY*OmB{sUs*3M;>F_BQ6a8+O0E`SIR-<~ws=sgxWd^6&n1`!_pJPl%^Y z{Ea572(Brh3)-bXKt&*-GH{?y-)$B4^R=A91^J&eA=e~nK(~mv&Bn2e#Xt;XM{d(d zv_~{@QdcWKNZqdLL}@{X2Zxd2N}lJvHX>yrv(^dEqEoHRnr}>a&^uDSXfF()VbuR} z(oc&jUgF<|ktW6vU= ziCDxkht|AGyDSArPO9h8N$1R=uT`Kc=oEQ&83KyM`qj6wyOL}SzrI80GA8%&PumpY z6Nccu_=%C2;1~@AJJynId~LgZOX?3;rtLnQ>3JDMB{yTQcidBY0aMIlmLLf|R9DZp z0Mhs`kScQc5x@h+00?8edjJUD-;$VFh;rschVT(fxRR+sfUsB}yY*yqICCS`jCRYl w7ij&n;F?o2x7jUwz}MpUqvPPTtA1!M%8$_A_Ol~|c$t%;{!+?i!jY~02GV1bUjP6A literal 0 HcmV?d00001 diff --git a/tests/pyc/assignment_identity.3.13.pyc b/tests/pyc/assignment_identity.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d19476a3ad706ce200bbac06a897cbf1043322f GIT binary patch literal 725 zcma)4yH3ME5WKS;2#G?#JVFqKK?ErcG(f|nf(j{3gp>|hj5G*@vPC#(cY2YT(qy9_#{^qQ-+*J_`uc5vi1fnIR6>R*Lft$df47pTHGnEjzgJWkd3Wszwc8EvBs+ zG>Q<4>vNXlB;UGZu!Tp#>Pz&PUhvC*?^Ofda#&md>sSb_da zo=-YYsF~5m)(oJYD)p^WcYmn)TGfglDV|jY5S_i-XiSJ7r{=Vg_!S6(3W|k_m9`dFm{Jb2>VYOQyMpPiOmCwI_W2Js z{)3cy*!c(4_ye4|^J2utg?-HK?96*_?v{oNIU4|0A7Abt-ZwL^6!(_+j=D1xgy4Y- z20TG1Jd4osY(m?U^H9kgrLVb&Tk`JC5rl96v#?GPul^WktInD=4{fpEhPE9^RDLaz zXX@BQ84ONNjpi=pQYl(AN*ld$99)@8RC>kNIykAHtGYQ1YJ`DlE?+dGtX3C3`bJ@n z3b|NM5`^&V6tBy-3wPz#;!}R4wbhZuE+ycXY>O^p*;8>%ztk;(P9hfh1xkhtL8=gk z=nxI)Qy*0g3v|eGc7lM|$KC6Vj+_p6Q*z8x;)r%{^~i_mil^-Xj#dTF=ZCUl;UJ|k z?#&gYQbJrvjUUz-n&8bALVOHZ!u`ml J@X1l^>kIF$fm;9o literal 0 HcmV?d00001 diff --git a/tests/test_assignment_identity.py b/tests/test_assignment_identity.py deleted file mode 100644 index b345425e..00000000 --- a/tests/test_assignment_identity.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Compile, decompile and execute original fixtures with CPython 3.11–3.14.""" - -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_independent_equal_assignments_keep_both_evaluations_and_true_chains(self): - sources = ( - "def select(read):\n left, right = read(), read()\n return left, right\n", - "def select(read):\n left = right = read()\n return left, right\n", - "def select(read):\n left, right = None, None\n return left, right\n", - "def select(read):\n right = left = None\n return left, right\n", - ) - for index, source in enumerate(sources): - original, recovered = self.recover(source) - self.assertEqual(original.__code__.co_code, recovered.__code__.co_code) - self.assertEqual( - original.__code__.co_exceptiontable, - recovered.__code__.co_exceptiontable, - ) - for failure_at in (None, 1, 2): - outcomes = [] - for function in (original, recovered): - events = [] - - def read(): - events.append(len(events) + 1) - if events[-1] == failure_at: - raise ValueError("owned evaluation") - return events[-1] - - try: - result = ("value", function(read)) - except ValueError as error: - result = (type(error).__name__, str(error)) - outcomes.append((result, events)) - with self.subTest(source=index, failure_at=failure_at): - self.assertEqual(outcomes[1], outcomes[0]) - if index == 0: - counter = iter((7, 8)) - self.assertEqual(recovered(lambda: next(counter)), (7, 8)) - - -if __name__ == "__main__": - unittest.main() From f47057c30f6b5686b9724b4554854d99ab6487f4 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:32:08 -0400 Subject: [PATCH 4/4] Add harness coverage for the reconstruction fix --- tests/behavior/cases/b_assignment_identity.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/behavior/cases/b_assignment_identity.py diff --git a/tests/behavior/cases/b_assignment_identity.py b/tests/behavior/cases/b_assignment_identity.py new file mode 100644 index 00000000..471da697 --- /dev/null +++ b/tests/behavior/cases/b_assignment_identity.py @@ -0,0 +1,20 @@ +# MIN_VERSION: 3.9 + + +def separate(read): + left, right = read(), read() + return left, right + + +def shared(read): + left = right = read() + return left, right + + +events = [] +def read(): + events.append("read") + return len(events) + +print(separate(read), events) +print(shared(read), events)