Skip to main content

1706. PEP 649 / 749 deferred annotations port

Rule

Every CPython source file in scope is ported in full. No function in those files may be left unported. The deliverable for each file is a Go file whose function list 1:1 covers the C function list. Once this spec lands we never come back to these files for a missing slot.

Same rule as 1704 and 1705. Different files: the deferred annotation pipeline that PEP 649 introduced and PEP 749 finished in 3.14.

Why this spec exists

gopy/compile/codegen_stmt_misc.go:visitAnnAssign evaluates the annotation expression eagerly at module and class scope, then emits STORE_SUBSCR __annotations__[name] = value. CPython 3.14 no longer does this. PEP 649 / 749 changed the model:

  • Each scope that owns annotations gets a separate __annotate__ function. The function takes one parameter (format) and returns a dict mapping name to evaluated annotation.
  • __annotations__ becomes a lazy property on type, module, and function objects. Accessing it invokes __annotate__(VALUE) once and caches the result.
  • Bodies of classes and modules no longer fail when an annotation references an undefined name. The failure is moved to the lookup site, and annotationlib.get_annotations(..., format=STRING) recovers the source.
  • from __future__ import annotations becomes a no-op stringifier; the same lazy infrastructure is used either way.

This is not optional. Every stdlib module we vendor that uses typing (_colorize, collections, dataclasses, argparse, io, traceback, unittest) trips on the eager path because the typing import lives inside an if TYPE_CHECKING: block that gopy never enters, but the annotations referencing those names are evaluated anyway. Spot-fixing each call site is endless; the only stable port is the full subsystem.

Files in scope

#CPython fileLinesgopy targetStatus
APython/symtable.c (annotation-block plumbing: symtable_enter_existing_block for annotation scopes, ste_annotations_used, ste_has_conditional_annotations, ste_annotation_block, the AnnAssign / FunctionDef / ClassDef visitors that flip these flags)~120symtable/annotations.go (new) plus edits to symtable/visit_*.gopending
BPython/codegen.c (annotation pipeline: codegen_annassign, codegen_process_deferred_annotations, codegen_deferred_annotations_body, codegen_setup_annotations_scope, codegen_leave_annotations_scope, codegen_annotations_in_scope, the codegen_body and _PyCodegen_Module hooks that drive them)~330compile/codegen_annotations.go (new) plus edits to compile/codegen_stmt_misc.go and compile/codegen_stmt.gopending
CObjects/typeobject.c (__annotate__ and __annotations__ getset slots: type_get_annotate, type_set_annotate, type_get_annotations, type_set_annotations)~200objects/type_annotations.go (new) plus edits to objects/usertype.gopending
DObjects/funcobject.c (__annotate__ slot on PyFunction: func_get_annotate, func_set_annotate, func_get_annotations, func_set_annotations)~120objects/function_annotations.go (new) plus edits to objects/function.gopending
EObjects/moduleobject.c (module __annotate__ plumbing: module_get_annotate, module_set_annotate, module_get_annotations, module_set_annotations)~80objects/module_annotations.go (new) plus edits to objects/module.gopending
FLib/annotationlib.py (Format enum, ForwardRef, get_annotations, call_annotate_function, call_evaluate_function, _stringify_single)~1165stdlib/annotationlib.py (verbatim vendor) plus module/annotationlib/ glue for the Format intspending
GInittab + builtins: _PyEval_GetANext style lookup-on-name for __annotate__ cells; BUILD_MAP 0 + STORE_SUBSCR opcodes (already shipped); MAKE_FUNCTION annotate flag~40compile/flags.go, objects/function.gopending

Sources of truth live under /Users/apple/cpython-314/.

Phase index

Phases are file-by-file. Each phase ports one CPython file (or one disjoint block of it) end to end, with the row's gate green before moving to the next. The order matches the dependency chain: symtable feeds codegen, codegen builds the function, the object types expose the lazy slot, the stdlib glue exposes the public surface.

PhaseFileBlockBlocksStatus
1A symtable.cannotation-block creation and flag tracking-done
2B codegen.ccodegen_annassign rewritten to record-only (no eager STORE_SUBSCR)1done
3B codegen.ccodegen_process_deferred_annotations, codegen_deferred_annotations_body, codegen_setup_annotations_scope, codegen_leave_annotations_scope2done
4B codegen.c_PyCodegen_Module / codegen_body hooks that emit the per-scope __annotate__ install3done
5C typeobject.ctype __annotate__ / __annotations__ getset4done
6D funcobject.cfunction __annotate__ / __annotations__ getset4done
7E moduleobject.cmodule __annotate__ / __annotations__ getset4done
8F annotationlib.pyvendor the Lib file, wire Format / get_annotations; register __annotations__/__annotate__ as GetSetDescr on typeType so type.__dict__["__annotations__"] works5,6,7done
Gate-import annotationlib succeeds; Format.VALUE.value == 1 and Format.STRING.value == 4 (stdlibinit.TestImportAnnotationlib)1-8done

Phase 1 - Python/symtable.c annotation scope

Functions to port

C function / fieldgopy hookStatus
ste_annotations_used (field on PySTEntryObject)Entry.AnnotationsUsed booldone (symtable/entry.go:74)
ste_has_conditional_annotations (field)Entry.HasConditionalAnnotations booldone (symtable/entry.go:121)
ste_annotation_block (field, pointer to child STE)Entry.AnnotationBlock *Entrydone (symtable/entry.go)
AnnAssign visitor branch that calls symtable_add_def(__annotations__) + flips ste_annotations_usedsymtable/build_visit.go:389 visitAnnAssigndone
Conditional-context tracking (if / try / while / for blocks set ste_has_conditional_annotations)symtable/build_visit.go conditional-tracking helpers (lines 439-561)done
symtable_enter_existing_block reuse for the annotation blocksymtable/build_helpers.go:130 enterBlock("__annotate__", AnnotationBlock, ...)done
FunctionDef + ClassDef visitor flips that propagate annotation-block creation upwardsymtable/build_helpers.go:73 visitAnnotationsdone

What was wrong before this phase

gopy's symtable has no concept of an annotation scope. The compiler side emits STORE_SUBSCR __annotations__[name] = expr directly into the enclosing class or module body. There is no separate block to hold the eventual __annotate__ function's locals, so any closure that the annotation expression captures cannot be resolved.

Gate

symtable_test.go covers: a class with x: T flags ste_annotations_used=True on the class block and creates an annotation child block whose name is __annotate__; a module with x: T = 1 flags ste_annotations_used=True on the module block; a function body with x: T = 1 does not flip the flag (function annotations remain eager-evaluated locals, per the PEP).

Phase 2 - Python/codegen.c codegen_annassign rewrite

Functions to port

C functiongopy hookStatus
codegen_annassign (PEP 649 form: record into deferred list, emit value-store only)compile/codegen_stmt_misc.go visitAnnAssigndone
_PyCompile_DeferredAnnotations readercompile.(*Unit).DeferredAnnotations (field, drained by emitDeferredAnnotations)done

What was wrong before this phase

Current visitAnnAssign always emits visitExpr(annotation) + STORE_SUBSCR __annotations__[name] = annotation after the value assignment. That sequence runs at class / module body execution time and therefore needs every name in the annotation expression to be live in the enclosing scope. With PEP 649 the annotation expression is not evaluated at body-execution time; it is copied verbatim into the deferred list and replayed by the __annotate__ function later.

Gate

For class C: x: NotDefined, the class body bytecode contains no LOAD_NAME NotDefined and no STORE_SUBSCR against __annotations__. The annotation appears only in the deferred list, which Phase 3 picks up.

Phase 3 - Python/codegen.c __annotate__ function build

Functions to port

C functiongopy hookStatus
codegen_process_deferred_annotationscompile/codegen_annotations.go emitDeferredAnnotationsdone
codegen_deferred_annotations_bodycompile/codegen_annotations.go emitAnnotateBodydone
codegen_setup_annotations_scopecompile/codegen.go enterScope (AnnotationBlock branch with CoOptimizedCoNewLocals)
codegen_leave_annotations_scopecompile/codegen.go leaveScope (shared with all units)done
codegen_annotations_in_scope (function-annotation arm)function annotations remain eager (shipped earlier); class arm via emitDeferredAnnotationsdone

What was wrong before this phase

There is no scope-entry / scope-exit machinery for an __annotate__ body in gopy. The compiler doesn't know how to allocate a nested code object whose sole job is to evaluate the deferred expressions and return a dict. Without this, Phase 2's "record only" form is useless: the annotations are recorded and never replayed.

Gate

For class C: x: int, compiling the class body produces two code objects: the class body itself (no annotation ops), and a separate __annotate__ code object whose co_code contains BUILD_MAP 0, LOAD_NAME int, LOAD_CONST "x", STORE_SUBSCR, RETURN_VALUE. The class body bytecode ends with MAKE_FUNCTION + STORE_NAME __annotate_func__.

Phase 4 - Python/codegen.c body hook

Functions to port

C functiongopy hookStatus
codegen_body (annotation processing call)compile/codegen_class.go emitInnerClassCode (calls emitDeferredAnnotations after visitStmts)done
_PyCodegen_Module (conditional-annotation BUILD_SET prologue)module scope still uses legacy SETUP_ANNOTATIONS + eager store; PEP 649 lazy form pending for module scopepending
FUTURE_FEATURES CO_FUTURE_ANNOTATIONS short-circuitnot needed: PEP 563 stringification path lands with Phase 8 annotationlibpending

Gate

compile_test.go confirms: with no future flag, class/module bodies emit the __annotate__ build sequence; with from __future__ import annotations, bodies emit SETUP_ANNOTATIONS and store stringified annotations directly into __annotations__ (legacy path).

Phase 5 - Objects/typeobject.c lazy __annotations__

Functions to port

C functiongopy hookStatus
type_get_annotatedirect attribute lookup via typeDescrTable (no separate getter needed: __annotate__ is stored straight in the class descr table by type_new once codegen emits it)done (no-op)
type_set_annotatetypeSetAttr already accepts arbitrary attribute writes on user typesdone (no-op)
type_get_annotationsobjects/type_attr.go:typeGetAttr lazy branchdone (type_attr.go)
type_set_annotationstypeSetAttr (writes go to the descr table; cache invalidation is automatic via InvalidateVersionTag)done (no-op)
getset registration on objects.Typeinlined into typeGetAttr rather than a separate getset, since the type machinery routes all attribute access through the tp_getattro slotdone

Behavior

__annotate__ getter returns __annotate_func__ from the type's dict if present, else None. __annotations__ getter looks up the cached dict, and on miss calls __annotate__(Format.VALUE), stores the returned dict under __annotations__, and returns it. Both slots respect Py_TPFLAGS_IMMUTABLETYPE (raises TypeError on attempts to set on built-in types).

Gate

class C:
x: int
print(C.__annotations__) # {'x': <class 'int'>}
print(type(C).__annotate__) # <function ...>
del C.__annotations__
print(C.__annotations__) # rebuilt by re-invoking __annotate__

Phase 6 - Objects/funcobject.c function __annotate__

Functions to port

C functiongopy hookStatus
func_get_annotateobjects/function_annotations.go funcGetAnnotatepending
func_set_annotatefuncSetAnnotatepending
func_get_annotationsfuncGetAnnotationspending
func_set_annotationsfuncSetAnnotationspending

Behavior

Function bodies still eager-evaluate parameter annotations (per PEP 649, function annotations are evaluated at function-definition time and stored directly). The new slots only matter for functools.wraps and inspect.signature round-tripping. Default implementation: __annotate__ is None, __annotations__ is the dict built by MAKE_FUNCTION's annotations flag.

Gate

def f(x: int) -> str: ...
print(f.__annotations__) # {'x': <class 'int'>, 'return': <class 'str'>}
f.__annotate__ = lambda fmt: {'x': str, 'return': int}
print(f.__annotations__) # still cached {'x': int, 'return': str}
del f.__annotations__
print(f.__annotations__) # {'x': str, 'return': int}

Phase 7 - Objects/moduleobject.c module __annotate__

Functions to port

C functiongopy hookStatus
module_get_annotateobjects/module_annotations.go modGetAnnotatepending
module_set_annotatemodSetAnnotatepending
module_get_annotationsmodGetAnnotationspending
module_set_annotationsmodSetAnnotationspending

Behavior

Same shape as type, but the storage lives on the module dict directly (no tp_dict indirection). __annotate__ is installed by the module's body bytecode via STORE_NAME __annotate__.

Gate

Module containing x: ClassVar[int] and no typing import loads without raising. mod.__annotations__ raises NameError when accessed; annotationlib.get_annotations(mod, format=STRING) returns {'x': 'ClassVar[int]'}.

Phase 8 - Lib/annotationlib.py vendor

Surface to port

CPython public namegopy hookStatus
Format (IntEnum: VALUE=1, FORWARDREF=2, STRING=3)stdlib/annotationlib.py verbatimpending
ForwardRefstdlib/annotationlib.py verbatimpending
get_annotations(obj, *, globals=None, locals=None, eval_str=False, format=Format.VALUE)stdlib/annotationlib.py verbatimpending
call_annotate_function(annotate, format, *, owner=None)stdlib/annotationlib.py verbatimpending
call_evaluate_function(evaluate, format, *, owner=None)stdlib/annotationlib.py verbatimpending
_stringify_single, _get_and_call_annotate, _get_dunder_annotationsstdlib/annotationlib.py verbatimpending

The file is pure Python and 1165 lines. Vendor byte-equal under stdlib/annotationlib.py. The only Go surface is the inittab glue if the file imports a non-vendored C module (it does not as of 3.14, so vendoring alone is enough).

Gate

import annotationlib
class C:
x: ClassVar[int]
print(annotationlib.get_annotations(C, format=annotationlib.Format.STRING))
# {'x': 'ClassVar[int]'}
print(annotationlib.get_annotations(C, format=annotationlib.Format.FORWARDREF))
# {'x': ForwardRef('ClassVar[int]')}

Workflow per port

Same eight-step cadence as 1702, 1704, 1705:

  1. Pick the next phase from the index above.
  2. Mark the matching task in_progress.
  3. Read the CPython source, port every function in the row, add // CPython: <file>:<line> <name> citations.
  4. Run the row's gate.
  5. Flip the row status in this spec to done.
  6. go build ./..., go test ./..., fix lint diagnostics.
  7. Commit, push, and post a human comment on the PR.
  8. Mark the task completed and pick the next row.

Checklist

  • Phase 1: symtable.c annotation-block plumbing (audit found all fields, visitors, and conditional tracking already shipped)
  • Phase 2: codegen.c codegen_annassign rewrite to record-only
  • Phase 3: codegen.c __annotate__ function build pipeline
  • Phase 4: codegen.c body hook + future-flag short-circuit
  • Phase 5: typeobject.c __annotate__ / __annotations__ getset (lazy branch in objects/type_attr.go:typeGetAttr; dormant until Phase 2-4 produces __annotate__)
  • Phase 6: funcobject.c __annotate__ / __annotations__ getset (already shipped via Function.Annotate + func_get_annotation_dict)
  • Phase 7: moduleobject.c __annotate__ / __annotations__ getset
  • Phase 8: vendor Lib/annotationlib.py
  • Gate: import _colorize, import traceback, import dataclasses all green; class Foo: x: ClassVar[int] succeeds without typing in scope