Skip to content

Change the typing spec around string references - #2144

Open
davidhalter wants to merge 21 commits into
python:mainfrom
davidhalter:string-annotations
Open

Change the typing spec around string references#2144
davidhalter wants to merge 21 commits into
python:mainfrom
davidhalter:string-annotations

Conversation

@davidhalter

@davidhalter davidhalter commented Jan 4, 2026

Copy link
Copy Markdown
Collaborator

I added this after the discussion here: https://discuss.python.org/t/annotation-string-references-in-class-scope-in-conformance-tests/105439

I'm not 100% sure about the wording, but I hope the direction is fine. I would like to gather some feedback before presenting this to the typing council.

Please also merge #2139 before this pull request. Otherwise it will be very hard to update Zuban's conformance test results in this pull request.

@davidhalter
davidhalter marked this pull request as ready for review January 4, 2026 23:55
@davidhalter
davidhalter marked this pull request as draft January 4, 2026 23:55
@davidhalter

Copy link
Copy Markdown
Collaborator Author

@JelleZijlstra Could you please pre-review this? What do you think about this spec change?

Comment thread docs/spec/annotations.rst Outdated
@srittau srittau added the topic: typing spec For improving the typing spec label Jan 5, 2026
Comment thread conformance/tests/annotations_forward_refs.py Outdated
Comment thread conformance/tests/annotations_forward_refs.py Outdated
@davidhalter

Copy link
Copy Markdown
Collaborator Author

I think I have integrated all the changes. Is it time to open an issue on the Typing Council’s issue tracker asking for a decision?

Comment thread docs/spec/annotations.rst Outdated

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One wording nit, one formatting nit, and one conformance suite nit :) But overall this looks great to me.

Comment thread docs/spec/annotations.rst Outdated
Comment thread docs/spec/annotations.rst Outdated

@rchen152 rchen152 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good to me - much more consistent and clearly specified than before

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks good to me. Thanks @davidhalter for getting this clarified.

@davidhalter

Copy link
Copy Markdown
Collaborator Author

I have integrated all of Carl's suggestions. I will update the conformance tests as soon as the typing council approves this change. If I update it now we probably just run into merge conflicts, since especially pyrefly changes a lot.

@carljm Please let me know if you think something needs more work.

@zzzeek

zzzeek commented Mar 12, 2026

Copy link
Copy Markdown

Hi, can someone explain the intent of this change to me?

Given, under python 3.14:

Python 3.14.0 (main, Oct 20 2025, 16:44:45) [GCC 14.3.1 20250808 (Red Hat 14.3.1-3)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A: pass
... 
>>> class X:
...     def A(self) -> "A": pass
...     
>>> class Y:
...     def A(self) -> A: pass
...     
>>> typing.get_type_hints(X.A)
{'return': <class '__main__.A'>}
>>> typing.get_type_hints(Y.A)
{'return': <function Y.A at 0x7f2052bc3c10>}
>>> 

does this change propose that it would be impossible for get_type_hints(X.A) to return class A under any circumstances, even with the quotes?

@JelleZijlstra

Copy link
Copy Markdown
Member

Yes

@zzzeek

zzzeek commented Mar 12, 2026

Copy link
Copy Markdown

are you going to change the behavior of get_type_hints() ? is this a 3.15 change? is there a pep? it should be apparent that this is an enormous backwards-incompatible change I hope?

edit: the pep is pep-749

@zzzeek

zzzeek commented Mar 12, 2026

Copy link
Copy Markdown

also is this change intended to take place regardless of whether a file is in pep-563 mode or pep-649 mode?

@AlexWaygood

Copy link
Copy Markdown
Member

I fully agree, get_type_hints definitely shouldn't be used as any kind of a reference here -- it has very questionable behaviour even on the latest Python version

@rchen152

rchen152 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Is there a better reference than typing.get_type_hints we can use then? My (possibly mistaken) understanding was that get_type_hints is the closest thing we have to an "official" runtime interpretation of type hints and therefore the thing to look at if we're concerned about compatibility with runtime behavior and previous versions.

To be clear, I think that if we were starting from a clean slate, a simple and consistent interpretation of type annotations as proposed here would be ideal. But we're not, so I'm trying to understand what a compromise that still moves us in the right direction would look like.

@jorenham

jorenham commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

I'd rather not rely on the behavior of get_type_hints() too much, it's deliberately buggy for backwards compatibility.

Hmm this is also news to me.

Then in that case I agree that we shouldn't build upon unstable grounds. But at the same time I also think it's a good idea to minimize the differences between the static- and runtime-typing worlds.
Changing perspective, maybe there's some way that we could "fix" get_type_hints (or perhaps introduce an alternative) so that it'll match the behavior of whatever we decide here in this PR (i.e. after this is merged)?

@AlexWaygood

Copy link
Copy Markdown
Member

The non-buggy modern alternative is annotationlib.get_annotations, which is already backported in typing_extensions. I don't think it's workable to change get_type_hints; its questionable behaviour is too deeply embedded

@jorenham

jorenham commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

The non-buggy modern alternative is annotationlib.get_annotations,

Ah perfect; that sounds like a good alternative to typing.get_type_hints that we could reference here instead.

@JelleZijlstra

Copy link
Copy Markdown
Member

Generally the reference should be what happens if you don't stringify the annotation in 3.14+. Annotations are now always lazily evaluated.

@rchen152

rchen152 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Perfect, thanks. I retried using annotationlib.get_annotations in 3.14 and typing_extensions.get_annotations in 3.12. [1] The matrix came out the same as it did for typing.get_type_hints.

If the intended reference is the non-stringified 3.14 results, then this PR matches the intended runtime behavior in 3.14 onward, which is great.

def int(self) -> int: ... still gives a different result from def int(self) -> "int": ... with get_annotations(eval_str=True); is that intentional? That particular example seems to be the exact case in which the backwards compatibility implications of this change are the most concerning.

[1] using this code, if anyone wants to check my homework ;) I added from __future__ import annotations and eval_str=True to see how stringified annotations evaluate.

from annotationlib import get_annotations

class A:
  str: str

class B:
  x: int
  def int(self): ...
  y: int

class C:
  def int(self) -> int: ...

print(get_annotations(A))
print(get_annotations(B))
print(get_annotations(C.int))

@carljm

carljm commented Aug 10, 2026

Copy link
Copy Markdown
Member

@rchen152 Yes, I think that's a good summary of my suggestion above: to match the runtime behavior of 3.14 exactly, assuming that typing.get_type_hints or annotationlib.get_annotations(eval_str=True) is used to access the annotations and resolve stringified ones. Let's call this proposal "compatible".

The alternative proposal on the table we could call "simple" -- that proposal is that all annotations (including stringified ones) should be resolved the way they would resolve at runtime if they were non-stringified annotations on 3.14+.

To the extent that stringified annotations (mostly) eventually go away, the two proposals will converge, since they differ only in the handling of stringified annotations.

The advantage of "simple" is that there is a single consistent model of name resolution for type checkers to implement. The disadvantages are:

  • To be compliant, type checkers have to outright break backwards compatibility for existing code using stringified annotations in these edge cases, as mentioned by some users above.
  • Type checkers will resolve stringified annotations in a way that does not match the semantics used by the only tools the stdlib provides to resolve them at runtime. (Thus we can't really claim there is a "single consistent model" for users to understand.)

The "compat" model preserves backwards-compatibility and full consistency with runtime behavior, allowing users to migrate to the new name-resolution semantics on their own schedule (by moving from stringified annotations to non-stringified PEP 647/747 in 3.14+). The cost is more complexity in type checker implementations.

It increasingly seems to me that "compat" is the choice that better serves users of the type system.

(There is a third possible choice, which is to fully embrace the backwards-incompatible change by also changing the runtime behavior of typing.get_type_hints, but I don't see any appetite for that.)

@davidhalter

davidhalter commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

What's the consensus here between Carl's simple and compat proposal? I'm personally for "simple", because I don't like the complexity of compat. I'm aware of the runtime issues, but I think most typing users do care about static analysis and not runtime. It seems unexpected that changing Foo[int] to "Foo[int]" would change its meaning.

I would like to move forward with this, because currently it feels like there are 6 competing ways between type checkers how we see forward references and I'm happy to move on with the majority of the council here.

@carljm

carljm commented Aug 31, 2026

Copy link
Copy Markdown
Member

I am happy to move forward with either, if authors of other type checkers are OK with a backwards-incompatible change to their handling of stringified annotation forward references in the ambiguous cases. ty already implements "simple", so that's less work for me :)

@rchen152

Copy link
Copy Markdown
Collaborator

I prefer "simple", although I'd also be okay with "compat". Given the concerns Carl raised, I was going to try implementing it in Pyrefly to make sure I didn't run into any showstopping issues before offering an opinion, but if it's already how ty works, I'm much less worried on that front.

(I'll still implement it in Pyrefly as soon as I can and report back if I run into unanticipated problems, but I don't think we need to wait on that.)

@davidhalter
davidhalter marked this pull request as ready for review August 31, 2026 19:41
@davidhalter

Copy link
Copy Markdown
Collaborator Author

I ran the tests with all the type checkers and added notes.

@davidhalter

Copy link
Copy Markdown
Collaborator Author

@carljm , I think you're the only one who has not ticked off python/typing-council#51.

I guess that there won't be more feedback here, so I feel like if you're ok with this change, we should be able to merge.

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did another review pass on this.

Comment thread docs/spec/annotations.rst
Comment on lines +226 to +233
The string literal should contain a syntactically valid Python expression
(i.e., ``compile(lit, '', 'eval')`` should succeed) that is 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should explicitly specify that the same rules apply to annotations implicitly stringified by from __future__ import annotations. And this should be tested in the conformance suite, too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I added it to the spec. Please review. I will copy the tests/annotations_forward_refs.py to tests/annotations_forward_refs_future.py and add notes once we agree on the rest in this PR.

Comment thread conformance/tests/annotations_forward_refs.py
Comment thread conformance/tests/annotations_forward_refs.py Outdated
Comment thread conformance/tests/annotations_forward_refs.py Outdated
Comment thread conformance/tests/annotations_forward_refs.py Outdated
@@ -79,23 +78,21 @@ class ClassD:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should also have at least one example of a valid forward reference -- all the below examples now error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I added a few. The only questionable one might be Line 83, because that's a legitimate failure now pre-Python3.14. I can also make it a # E? This will be a mandatory error in the future, once pre-3.14 Python is end-of-life. Let me know what you think.

Does not report error for a forward reference that is not enclosed in quotes.
Does not report error for use of quoted type with `|` operator (runtime error).
Incorrectly generates error for quoted type defined in class scope.
Resolves forward references in type annotations at the point of definition instead of end-of-scope

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Several type checkers have this note, but I don't think it's really accurate as a blanket statement for any of them; each type checker has subtleties about how specific cases are handled. I think ideally these notes would be more specific to the precise test case(s) actually failed by the checker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have tried to improve the notes slightly. To me it feels like

Resolves forward references in type annotations in classes at the point of definition instead of end-of-scope

Represents Mypy, Pyroscope and Pyrefly pretty well. They all have the same issues.

Comment thread docs/spec/annotations.rst

The presence of the import `from __future__ import annotations` must not
influence type checking. Annotations must be resolved in the exact same way as
if the import was not present.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, I should have been clearer in my last comment. I do not believe that this is the equivalence we should specify. Part of the point of using from __future__ import annotations is to enable forward references in type annotations, since the annotations are stringified at runtime and won't error. I think the equivalence that we should specify (and test) is that from __future__ import annotations causes all annotations to behave the same way as if they were explicitly stringified, using end-of-scope resolution.

For a pre-3.14 target, this should work with from __future__ import annotations but fail without it:

class C:
    x: B
    
    class B: ...

ClassC: "ClassC"
bytes_direct: bytes
bytes: "bytes"
inner1: ClassInner

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I agree with your suggestion to use # E? here. This unquoted reference raises NameError on Python 3.12, which the suite currently targets, and works on 3.14. So a type checker erroring here under 3.12 is behaving correctly, IMO. It's the same version-dependent situation as the existing cases at lines 22, 23, and 65.

Does not report error for a forward reference that is not enclosed in quotes.
Does not report error for use of quoted type with `|` operator (runtime error).
Incorrectly generates error for quoted type defined in class scope.
Resolves forward references in type annotations in classes at the point of definition instead of end-of-scope

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this still overgeneralizes slightly: mypy accepts the new inner2: "ClassInner" before ClassInner is defined, so it does resolve some class forward references to later definitions.

I think we could literally just add the word "some" and avoid inaccuracy here, without needing to precisely describe the failed cases.

Suggested change
Resolves forward references in type annotations in classes at the point of definition instead of end-of-scope
Resolves some forward references in type annotations in classes at the point of definition instead of end-of-scope

...

inner_after1: ClassInner
inner_after2: "ClassInner"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would add assert_type checks for these valid attributes, especially bytes and the nested-class references. At the moment, a checker could silently resolve them to Any and still pass.

For example:

def check_valid_attributes(d: ClassD) -> None:
    assert_type(d.bytes, bytes)
    assert_type(d.inner2, ClassD.ClassInner)
    assert_type(d.inner_after2, ClassD.ClassInner)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

topic: typing spec For improving the typing spec

Projects

None yet

Development

Successfully merging this pull request may close these issues.