Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions conformance/tests/annotations_forward_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


import types
from typing import assert_type
from typing import assert_type, Any


def func1(
Expand Down Expand Up @@ -57,10 +57,9 @@ def invalid_annotations(
pass


# > It should evaluate without errors once the module has been fully loaded.
# > The local and global namespace in which it is evaluated should be the same
# > namespaces in which default arguments to the same function would be evaluated.

# > Names within the expression are looked up in the same way as they would be
# > looked up at runtime in Python 3.14 and higher if the annotation was not
# > enclosed in a string literal.

class ClassB:
def method1(self) -> ClassB: # E?: Runtime error prior to 3.14
Expand All @@ -79,23 +78,19 @@ class ClassD:

ClassF: "ClassF" # E: circular reference

str: "str" = "" # OK
str: "str" = "" # E: circular reference

def int(self) -> None: # OK
...

x: "int" = 0 # OK

y: int = 0 # E: Refers to local int, which isn't a legal type expression

x: "int" = 0 # # E: Refers to a local int as well
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this example would be even more illuminating if placed before def int

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think I should move both x and y? In 3.14 it should not matter where the annotations are defined, but type checkers might still work a bit different.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're aiming for this test to really clarify the behavior, I think having a version of both x and y, both before and after def int, would be useful. Their behavior should not be the same. The stringified version should refer to def int regardless of location; the non stringified version will refer to the global int if before def int

Copy link
Contributor Author

@davidhalter davidhalter Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not how I understand Python 3.14 works. Maybe @JelleZijlstra can clarify. If I understand it correctly, both before and after would refer to the def int.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3.14 behavior would be that before or after def int, the annotation int refers to def int. So that should be the behavior of the stringified "int" annotation.

The behavior of the unstringified int annotation should depend on the Python version. Checking under 3.14 it should behave the same as the stringified annotation. Checking under an earlier Python, it should resolve as I described above.

I don't interpret this PR to be specifying that non-stringified annotations in Python versions earlier than 3.14 should behave as if under 3.14.

So you're right -- my comment above was making an unwarranted assumption that the conformance tests specify behavior on an older version of Python. But there's no reason that should be assumed. I think this highlights an existing problem, which is that it's not clear (or documented anywhere, as far as I can find) what Python version the conformance tests should assume.

If the conformance tests should always be assumed to check under the most recent Python, then stringified and non-stringified annotations should behave the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Now I'm just not sure what to do. 😄 What would you suggest?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably for purposes of this PR the best option is to avoid checking behavior that should depend on the Python version? So that implies avoiding an unstringified int annotation before def int. Which is fine -- there already isn't one. So just ignore that part of my suggestion :)

I also think the conformance tests should ideally be clearer about Python version, and probably include a facility for explicitly checking some files under a specified version. But that's a bigger project, definitely out of scope for this PR.

Copy link
Member

@carljm carljm Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think having z: "int" before def int, as well as the x: "int" after (my original suggestion in this thread) is useful, and doesn't depend on Python version.

It's the expanded idea of also having foo: int before def int that would depend on Python version, and thus isn't worth doing here.


def __init__(self) -> None:
self.ClassC = ClassC()


assert_type(ClassD.str, str)
assert_type(ClassD.x, int)


# > If a triple quote is used, the string should be parsed as though it is implicitly
# > surrounded by parentheses. This allows newline characters to be
# > used within the string literal.
Expand Down
34 changes: 8 additions & 26 deletions docs/spec/annotations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,32 +222,14 @@ String annotations
When a type hint cannot be evaluated at runtime, that
definition may be expressed as a string literal, to be resolved later.

A situation where this occurs commonly is the definition of a
container class, where the class being defined occurs in the signature
of some of the methods. For example, the following code (the start of
a simple binary tree implementation) does not work::

class Tree:
def __init__(self, left: Tree, right: Tree):
self.left = left
self.right = right

To address this, we write::

class Tree:
def __init__(self, left: 'Tree', right: 'Tree'):
self.left = left
self.right = right

The string literal should contain a valid Python expression (i.e.,
``compile(lit, '', 'eval')`` should be a valid code object) and it
should evaluate without errors once the module has been fully loaded.
The local and global namespace in which it is evaluated should be the
same namespaces in which default arguments to the same function would
be evaluated.

Moreover, the expression should be parseable as a valid type hint, i.e.,
it is constrained by the rules from :ref:`the expression grammar <expression-grammar>`.
The string literal should contain a syntactically valid Python expression
(i.e., ``compile(lit, '', 'eval')`` should succeed) that evaluates to a valid
:term:`annotation expression`. Regardless of the Python version used, names within the expression are looked up in the
same way as they would be looked up at runtime in Python 3.14 and higher if the
annotation was not enclosed in a string literal. Thus, name lookup follows
general rules (e.g., the current function, class, or module scope first, and
the builtin scope last), but names defined later within the same scope can be
used in an earlier annotation.

If a triple quote is used, the string should be parsed as though it is
implicitly surrounded by parentheses. This allows newline characters to be
Expand Down