Skip to content

Imitate Format.AST proposal using bytecode decompiler - #130

Merged
msullivan merged 34 commits into
mainfrom
bytecode-nonsense
Oct 13, 2026
Merged

msullivan merged 34 commits into
mainfrom
bytecode-nonsense

Conversation

@msullivan

Copy link
Copy Markdown
Collaborator

Implement a bytecode decompiler for annotate functions that can
handle the annotations that PEP 827 uses (except GenericCallable,
which I am going to get rid of).

This is more slop than I usually aim for.

Dealing with all of the things traditionally stored in annotations was
pretty easy. The tricky parts were all in conditionals and loops.

Conditionals fork and run both branches until either they join back up
or both exit. We then do a bunch of annoying and somewhat ad-hoc stuff
to try to produce good ASTs for them:

  • We look at source spans to distinguish list[t if b else f] from
    list[t] if b else list[f], since those will sometimes compile
    to the same bytecode.
  • We do a bunch of mostly ad-hoc simplifications to reduce things to
    and and or, etc. I had a version that tried much harder to
    always produce the original ast, by also looking at source spans,
    but decided it wasn't worth the trouble.

tests/_annos.py has 100 functions targeting Python 3.14 (PEP 649 deferred
eval, PEP 695 type params): forward refs, unions, generics, callables,
async types, Literal, Annotated, TypeGuard/TypeIs, ParamSpec, TypeVarTuple,
Self, nested classes, protocols, TypedDict, and deeply nested types.

tests/dump_annos.py enumerates all functions and prints three views of each
annotation: the evaluated VALUE (annotationlib), the STRING form (from a
temp copy with `from __future__ import annotations`), and the AST parse.
Stack-based interpreter that walks dis.Bytecode of __annotate__ methods
and reconstructs equivalent ast.expr nodes. Handles both function
annotations (BUILD_MAP N) and class annotations (BUILD_MAP 0 +
STORE_SUBSCR). Passes 112/113 annotations from the test suite; the one
miss is a lambda inside Annotated which needs a full Python decompiler.

Library in typemap/type_eval/_decompile.py, test harness in
tests/decompile_annos.py.
Handle the two control flow patterns CPython 3.14 generates for
conditional type annotations (e.g. `int if T else str`):

  - Inline: TO_BOOL → POP_JUMP_IF_FALSE → true branch → JUMP_FORWARD →
    false branch → join point.  Produces ast.IfExp on the stack.

  - Tail-position: each branch independently builds the annotation dict
    and returns.  Recursively decompile both branches and merge with
    IfExp wrappers where values differ.

Handles nested if-expressions, complex conditions (e.g. IsAssignable),
and mixed conditional/non-conditional annotations in both function and
class contexts.  Test suite now at 119 passed, 1 xfailed.
When CPython duplicates surrounding type context into both branches of
a tail-position if-expression (e.g. list[int] vs list[str] for
list[int if T else str]), the decompiler now pushes the IfExp down to
the point of actual divergence instead of wrapping the entire expression.

Handles Subscript, Tuple, BinOp, and Attribute with recursive structural
comparison.  Adds test cases for if-expressions nested inside list[],
dict[], union (|), tuple[], and Callable[].
The tail-position branches can't be isolated from the pre-branch stack
(e.g. list[int if T else str] puts list on the stack before branching,
and both branches consume it).  Go back to running both branches fully
via _run, then structurally factoring IfExp into the deepest divergence
point.

Note: list[int] if T else list[str] generates identical bytecode to
list[int if T else str] (CPython hoists list before the branch in both
cases), so we always normalize to the inner form.
The bytecodes for list[int if T else str] and list[int] if T else
list[str] ARE different (prefix context is on the stack before the
branch in the former).  However, postfix cases like (X if T else Y)|None
produce identical bytecode to (X|None) if T else (Y|None), so we can't
avoid factoring for those.  Since we can't distinguish postfix-shared
from deliberate-outer in the general case, we always factor — both
forms are type-equivalent anyway.
X[A if C else B] ≡ X[A] if C else X[B], and similarly for postfix
operators, so normalizing to the inner form is a correct transformation,
not a lossy one.
Decompile unpacked comprehension types (*[x for x in Iter[T]]) used
by PEP 827 type manipulation. Handles two bytecode patterns:

- Pattern A (class/method annotations): separate <listcomp> code object
  called via MAKE_FUNCTION + CALL
- Pattern B (module-level functions): comprehension inlined directly in
  the __annotate__ function with FOR_ITER loop

New handlers: GET_ITER, LIST_EXTEND, LIST_APPEND, CALL_INTRINSIC_1.
Comprehension filter clauses (if IsAssignable[...]) are also supported.
Extract _exec_stack_op() for shared stack opcodes (LOAD_*, BINARY_OP,
BUILD_TUPLE/LIST, MAKE_FUNCTION, LIST_EXTEND/APPEND, CALL_INTRINSIC_1)
and _handle_get_iter() for comprehension dispatch. Both _run, _run_expr,
and _decompile_comp_body now delegate to these instead of reimplementing
the same handlers. Net -108 lines.
- decompile_annos.py now auto-discovers all test .py files and runs
  the decompile-vs-string comparison on every annotation found
- CLI accepts file paths: `python -m tests.decompile_annos path ...`
- dump_annos.py gains collect_annotated() for generic annotation
  collection, load_stringified_copy() accepts arbitrary file paths
- _is_compiler_annotate() detects the LOAD_FAST/LOAD_SMALL_INT 2
  preamble to skip synthetic __annotate__ from TypedDict, dataclass,
  and NamedTuple metaclasses
- Modules with `from __future__ import annotations` are skipped
  (PEP 563 bytecode is not what the decompiler targets)
When a listcomp inside __annotate__ captures a free variable from an
enclosing class scope (e.g. T in __init_subclass__), the compiler
emits SET_FUNCTION_ATTRIBUTE to attach closure cells. Pop the closure
tuple and leave the function marker on the stack.
dis yields EXTENDED_ARG as a separate instruction (folding its value into
the next instruction's arg), and jumps can target it. Drop it, mapping its
offset to the following instruction.
The compiler folds e.g. -1 into a single constant; produce
UnaryOp(USub, 1) as the source AST has it.
Add generic CALL (positional args only) and <genexpr> code objects, so
calls like any(x for x in xs) decompile.  The compiler inlines any()/all()
over a generator expression behind a check that the name still refers to
the builtin; follow the plain call path instead of the inlined loop.

Factor the pc-changing ops shared by the interpreter loops (GET_ITER and
the builtin guard) into _exec_op.
A loop variable used in a nested scope (like a generator expression in the
comprehension body) is a cell, stored with STORE_DEREF.
LIST_EXTEND and LIST_APPEND mutated the target list node in place, but
tail-position if-expression branches run from copies of the same stack,
so items from both branches ended up in one shared list.  Build a new
node instead.

Also merge starred items by merging their values, since an
if-expression can't produce a starred item.
Bound both branches of an inline if-expression: the false branch of a
nested one used to run on past its join point.  And find the join from
the jump before the false branch, since when the true branch itself ends
in an if-expression, the optimizer threads that one's jumps straight to
the outer join.
Sometimes we get back equivalent but non-identical ASTs.
I didn't like any of the more elaborate tricks that the robot
came up with for dealing with them so...
Merge tail-position if-expression branches generically, which fixes
factoring inside calls, comparisons, not, and lists. Unify the
comprehension paths, tighten some checks, refresh docs, and rename
BindingMetadata to BindingNamespace.
BUILD_MAP was always treated as building the annotations dict, so a dict
display inside an annotation was silently decompiled into bogus
annotations. Build an ast.Dict instead, and only read annotations from
the dict an __annotate__ function returns.
@msullivan
msullivan merged commit 4f82733 into main Oct 13, 2026
5 checks passed
@msullivan
msullivan deleted the bytecode-nonsense branch October 13, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant