Skip to content

automatic forward references

an annotation can name a class defined further down, or the class it sits in:

def root() -> Tree:
    return Tree()


class Tree(list[Tree]):
    children: list[Tree]

    def add(self, child: Tree) -> Tree:
        self.append(child)
        return child

before 3.14, python evaluates these annotations as the def or class body runs, when Tree is not bound yet, and raises NameError. the conventional fix is to quote the reference. basedpython does the quoting for you, so for a target before 3.14 this transpiles to:

def root() -> "Tree":
    return Tree()


class Tree(list["Tree"]):
    children: "list[Tree]"

    def add(self, child: "Tree") -> "Tree":
        self.append(child)
        return child

scope

an annotation python evaluates as its definition runs is quoted when it names something that is not bound by then: a class defined further down, the class the annotation sits in, or a name imported only under if TYPE_CHECKING:, which never runs at all. that covers parameter and return annotations, and the annotations of class-body and module-level variables. the whole annotation is quoted, so a basedpython type inside it (Tree?, (Tree) -> None) is quoted in its lowered form ("Tree | None", "Callable[[Tree], None]")

a name that is already bound is left alone, as is a local variable's annotation, which python never evaluates

a class's subscript bases evaluate while the class is being built, and so do value-position subscripts in its body (list[Tree]()). a self-reference there is quoted where it stands: class Tree(list["Tree"]). a direct base — class A(A) — is not quoted. that is always a runtime error, and quoting it would only mask the bug. method bodies are not rewritten either: by the time a body runs, the class is bound, and quoting would produce a string instead of a value

why automatic

whether a name is bound by the time an annotation runs is a question about the program's bindings, which the checker already answers. basedpython reads every annotation as deferred, so you write the unquoted form everywhere and the transpiler quotes exactly the references that need it

converting python

in python a string annotation is a forward reference. in basedpython a string in an annotation is a literal type, so by transpile --reverse writes each one as the expression it spells: -> "Tree" becomes -> Tree, and "Tree | None" becomes Tree?. the arguments of Literal[…] and the metadata of Annotated[…] are values, and stay strings

when quoting is skipped

annotation quoting is only emitted when the annotation would otherwise be evaluated eagerly. it is skipped when:

  • the target is python 3.14 or newer — annotations are deferred natively (PEP 649), so the bare name resolves lazily
  • the file already defers every annotation through from __future__ import annotations, whether you wrote it yourself, opted into the blanket injection, or target a version below 3.10, which always gets it

a subscript base evaluates eagerly on every target, so its self-reference is quoted either way