From f8720ae576831d45e5551909e049775074cd82c7 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 16:16:10 -0400 Subject: [PATCH 1/2] Preserve deferred class annotation registration gates --- docs/LIMITATIONS.md | 3 + src/decompiler.rs | 55 ++++++++++++++---- .../fixtures/class_annotation_registration.py | 30 ++++++++++ tests/integration.rs | 22 +++++++ .../class_annotation_registration.3.14.pyc | Bin 0 -> 2944 bytes 5 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/class_annotation_registration.py create mode 100644 tests/pyc/class_annotation_registration.3.14.pyc diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 6c90274a..bdae38db 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -50,6 +50,9 @@ equivalent, or (b) a corner with no occurrence in the 520-module corpus. - Function/method/module/class-level annotations are restored to real annotation statements, including `__conditional_annotations__`-gated ones; runtime `get_annotations` VALUE/FORWARDREF match the original exactly. +- Live class registration gates retain `if True` blocks when necessary, + including constant-folded conditions, so recompilation preserves the + annotation function's closure and per-entry registration. - **Corner**: entries inside dead branches the compiler folded away (`if False:`) are dropped — VALUE/FORWARDREF semantics still match; only the STRING-format annotation dict differs (the original keeps the dead diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..d347e1a8 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -48628,7 +48628,7 @@ fn p649_leaked_gate(e: &ExprRef) -> Option { /// statements, recursively — conditional annotations (`if TYPE_CHECKING: /// x: T`) leave their markers inside the nested block. A marker directly /// after `name = value` merges into `name: T = value`. -fn p649_restore_gates(stmts: &mut Vec, entries: &mut Vec) { +fn p649_restore_gates(stmts: &mut Vec, entries: &mut Vec, class_scope: bool) { let mut i = 0; while i < stmts.len() { let gate = match &stmts[i] { @@ -48654,6 +48654,20 @@ fn p649_restore_gates(stmts: &mut Vec, entries: &mut Vec) { annotation: ent.ann.clone(), value: merged, }; + // A class's annotation registration gate is compiler-visible + // even when its condition was constant-folded. An unconditional + // source block retains that registration without emitting a + // runtime condition. Plain AnnAssign would lose the cell and + // the annotation function's guarded entries on recompilation. + let ann_stmt = if class_scope { + Stmt::If { + cond: Rc::new(Expr::Const(Rc::new(PyObject::True))), + body: vec![ann_stmt], + orelse: vec![], + } + } else { + ann_stmt + }; if has_value { stmts.remove(i - 1); stmts[i - 1] = ann_stmt; @@ -48668,26 +48682,47 @@ fn p649_restore_gates(stmts: &mut Vec, entries: &mut Vec) { Stmt::If { body, orelse, .. } | Stmt::While { body, orelse, .. } | Stmt::For { body, orelse, .. } => { - p649_restore_gates(body, entries); - p649_restore_gates(orelse, entries); + p649_restore_gates(body, entries, class_scope); + p649_restore_gates(orelse, entries, class_scope); } Stmt::Try { body, handlers, orelse, finalbody, .. } => { - p649_restore_gates(body, entries); + p649_restore_gates(body, entries, class_scope); for h in handlers.iter_mut() { - p649_restore_gates(&mut h.body, entries); + p649_restore_gates(&mut h.body, entries, class_scope); } - p649_restore_gates(orelse, entries); - p649_restore_gates(finalbody, entries); + p649_restore_gates(orelse, entries, class_scope); + p649_restore_gates(finalbody, entries, class_scope); } _ => {} } i += 1; } + // Keep one readable registration block for consecutive gated fields. + // Each field still has its own compiler gate index in __annotate__. + if class_scope { + fn annotation_block(stmt: &Stmt) -> bool { + matches!(stmt, Stmt::If { cond, body, orelse } + if matches!(&**cond, Expr::Const(value) if matches!(&**value, PyObject::True)) + && orelse.is_empty() && !body.is_empty() + && body.iter().all(|stmt| matches!(stmt, Stmt::AnnAssign { .. }))) + } + let mut index = 1; + while index < stmts.len() { + if annotation_block(&stmts[index - 1]) && annotation_block(&stmts[index]) { + let Stmt::If { body: current, .. } = stmts.remove(index) else { unreachable!() }; + if let Stmt::If { body, .. } = &mut stmts[index - 1] { + body.extend(current); + } + } else { + index += 1; + } + } + } } /// 3.14 PEP 649: rebuild module/class-scope annotation statements from /// the compiler-generated `__annotate__` machinery (see P649Entry). -fn restore_pep649_annotations(body: &mut Vec) { +fn restore_pep649_annotations(body: &mut Vec, code: &CodeObject) { // 1) locate the pseudo def and extract its entries let mut entries: Vec = Vec::new(); let mut def_pos: Option = None; @@ -48768,7 +48803,7 @@ fn restore_pep649_annotations(body: &mut Vec) { // 3) gated (module-scope) entries: replace their `{IDX}` markers, // recursively through nested blocks - p649_restore_gates(body, &mut entries); + p649_restore_gates(body, &mut entries, code.name != "" && code.flags & 1 == 0); // gated entries whose `{IDX}` marker never appeared were dead in the // executable flow (e.g. folded `if False:` bodies — the compiler drops @@ -48977,7 +49012,7 @@ fn postprocess_body(mut body: Vec, code: &CodeObject, version: PythonVersi // `__annotate_func__`) instead of inline `__annotations__` subscript // stores. Restore them to AnnAssign statements and strip the // protocol remnants (see restore_pep649_annotations). - restore_pep649_annotations(&mut body); + restore_pep649_annotations(&mut body, code); // __module__ / __qualname__ / __doc__ handling for class bodies let mut idx = 0; while idx < body.len() { diff --git a/tests/fixtures/class_annotation_registration.py b/tests/fixtures/class_annotation_registration.py new file mode 100644 index 00000000..60ae6d60 --- /dev/null +++ b/tests/fixtures/class_annotation_registration.py @@ -0,0 +1,30 @@ +if True: + module_count: int = 5 + + +class Always: + "Documented class." + if True: + amount: int = 3 + label: str = "ready" + + def read(self) -> int: + return self.amount + + +def select(enabled, values, default): + class Result: + if True: + amount: int = 3 + if enabled: + label: str = "ready" + for value in values: + limit: int = default(value) + return Result + + +def loop_only(values, default): + class Result: + for value in values: + amount: int = default(value) + return Result diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..171a9fae 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,25 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn deferred_class_annotations_keep_registration_gates() { + let fixture = "class_annotation_registration.3.14.pyc"; + let (src, clean) = decompile_fixture(fixture); + assert!(clean, "output marked incomplete:\n{src}"); + assert!(src.starts_with("module_count: int = 5\n"), "{src}"); + assert_contains( + &src, + &[ + "class Always:", + "'''Documented class.'''", + " if True:\n amount: int = 3\n label: str = 'ready'", + " if enabled:\n if True:\n label: str = 'ready'", + " for value in values:\n if True:\n limit: int = default(value)", + " for value in values:\n if True:\n amount: int = default(value)", + "def read(self) -> int:", + ], + fixture, + ); + assert!(!src.contains("__conditional_annotations__"), "{src}"); +} diff --git a/tests/pyc/class_annotation_registration.3.14.pyc b/tests/pyc/class_annotation_registration.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c17f634801eade8738a83a15b76263f08e5abda7 GIT binary patch literal 2944 zcmbVO-ESL35TCs-+p*K6Y2(zfTbxvFV+u~IRun{31-0o5kb;Z+0Bug!^O;&)d}VJ9 zgbK+*m2lJth{Ri8`r7}7P$8KUQYDa(cuV4vw0&Y`@9a2kDgi5byq~i(v%lZWojx!; zqyoUg@0Y(R8slgGz<#f3QLl3mI~Fvdq)&j*&wzj>U9cOl1fQ{2U>v5P#3rDmoL9mV zLMf@ZQ!QyIwUTih3bBvuo?h%k8=+H?sx8)p8z5K-go3KoG^lL1< ztfUA7`;0Cw!#PG5xpBJ49Tl-Ml-W6j%yi=-UNS_Y3}UDhLj&T0@s9hYwc>w$@Umw& zmYuqADtX(re7_jz+_5SvHbZ0RM~y6`$aL~3=Fq+}EGci^1*qU`4nAbJ(7J2v6PRVF zUHOal2L^XUgV!IjK&{n9K?#hi$CoV;44bCBs#3FsY5KHzx*5z0#~1!=we}UBa{O7j z#kA^mPgtVn)lKevTJr^$gW~O#AZ2z}9n%aBg$@<_CzGhzKaApO6Lx^<*-a8_w*2eh zJV-C8Lyf#ef8w1;2o zSYf!L&qh~YHu?BUOLT-P)Uiu9ak$PL2d{CwyjdCN&i2TxmM+Zjeb?}GGHp0-Ivw~;CucV0zdfyFF*Y04XA zMN(ERfuu3(W{}%zStVN8T@Cb4SfSSW0mYn3div7F=x*AODMRa~j5K_#o{|T<2*R{N z2}LFmT-8S5$|!&^u&+$P4dZ;21VjvXDU@p79Ro&Kx0_f=hJGaOY48@DN<^O?xNg1F65k-yYpzYE@VhA}=TdoN{ zfkyO`A%p}y*B;OZ$M<({Px`?yf}iCN z5)T5NM124j>-$HQp(E9Sye#| zbIJg#vb(Gz9z{&iBBp3Prt}K*08{Y0086ofWgCk#mSkeJ7Gzw{yKQ=PcV&<)`}5?y zH0{6ggk0^zd>obh8=Rkfsd0OtP9XI$Ep(wivvz4cHM2!n??-cDUo;o+TnOnr^>z`g z?IJjY3js&Mg%BHNXbG;fI4?~g;7hcIu$-KFaN>Tkb>)}TObAPLUs$Sxh$O!E0Ga58 zfK%uR$P$y!2$PCl_#04c7=4(ej2UH3nWts8pU26UJ=%3#3!AL$Sc-FSy@%{>eeXSC zkcx_>Ek8jNnWSVfFEClh<=4n`UyY_hO&0l-NaV*5o5ZgmHu+e~{H$euJie}Fwp1L; z*IFzXp~^RKw=7_ijnaKt^YI#4DoJHnDTDZ2XwyZ00Vm~Ml$4%spjP%oLDlUendWPx# literal 0 HcmV?d00001 From 7393bfa2e0335251b8ea68debe5cd9438a09d5d8 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:35:07 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_class_annotation_registration.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/behavior/cases/b_class_annotation_registration.py diff --git a/tests/behavior/cases/b_class_annotation_registration.py b/tests/behavior/cases/b_class_annotation_registration.py new file mode 100644 index 00000000..b69c745f --- /dev/null +++ b/tests/behavior/cases/b_class_annotation_registration.py @@ -0,0 +1,31 @@ +# MIN_VERSION: 3.14 + + +class Always: + "Documented class." + if True: + amount: int = 3 + label: str = "ready" + + def read(self) -> int: + return self.amount + + +def select(enabled, values, default): + class Result: + if True: + amount: int = 3 + if enabled: + label: str = "ready" + for value in values: + limit: int = default(value) + return Result + + +print(Always.__annotate__.__code__.co_freevars) +print(sorted(Always.__annotations__)) +def default(value): + print("default", value) + return value +print(sorted(select(False, [], default).__annotations__)) +print(sorted(select(True, [3, 4], default).__annotations__))