Skip to content

[Bug] Behavior of typing_extensions.get_type_hints differs from typing.get_type_hints. #597

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
emcd opened this issue May 11, 2025 · 10 comments

Comments

@emcd
Copy link

emcd commented May 11, 2025

(Possibly related to #410, but filing this separate issue to confirm and to consider documentation improvements, if so.)

The following simple reproducer will result in an error on Python 3.13.3 (latest stable CPython as of this writing) and earlier versions:

from __future__ import annotations

import dataclasses as dcls
import inspect
# import typing as typ
import typing_extensions as typ


@dcls.dataclass
class Foo:

    y: int
    x: typ.ClassVar[ int ] = 1


print( Foo.__init__.__annotations__ )
print( inspect.get_annotations( Foo.__init__, eval_str = True ) )
print( typ.get_type_hints( Foo.__init__ ) )

The error is:

$ hatch run python bugs/dcls-classvar-hints.py
{'y': 'int', 'x': 'typ.ClassVar[int]', 'return': None}
{'y': <class 'int'>, 'x': typing.ClassVar[int], 'return': None}
Traceback (most recent call last):
  File "/home/me/src/somepkg/bugs/dcls-classvar-hints.py", line 17, in <module>
    print( typ.get_type_hints( Foo.__init__ ) )
  File "/home/me/.local/share/hatch/env/virtual/somepkg/QPsnosgX/dynadoc/lib/python3.10/site-packages/typing_extensions.py", line 1315, in get_type_hints
    hint = typing.get_type_hints(
  File "/usr/lib/python3.10/typing.py", line 1871, in get_type_hints
    value = _eval_type(value, globalns, localns)
  File "/usr/lib/python3.10/typing.py", line 327, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "/usr/lib/python3.10/typing.py", line 693, in _evaluate
    type_ = _type_check(
  File "/usr/lib/python3.10/typing.py", line 167, in _type_check
    raise TypeError(f"{arg} is not valid as type argument")
TypeError: typing.ClassVar[int] is not valid as type argument

Switching the import from typing_extensions to typing causes get_type_hints to work correctly (on both Python 3.10.12 and Python 3.13.3.):

$ hatch run python bugs/dcls-classvar-hints.py
{'y': 'int', 'return': None}
{'y': <class 'int'>, 'return': None}
{'y': <class 'int'>, 'return': <class 'NoneType'>}

(Note the absence of the ClassVar annotation. This suggests that we possibly have a toxic interaction with the dataclasses.dataclass decorator in the mix.)

Likewise, removing the from __future__ import annotations and using typing_extensions.get_type_hints also works correctly:

$ hatch run python bugs/dcls-classvar-hints.py
{'y': <class 'int'>, 'return': None}
{'y': <class 'int'>, 'return': None}
{'y': <class 'int'>, 'return': <class 'NoneType'>}

And, inspect.get_annotations works correctly in all cases. But, I would prefer to use get_type_hints as it automatically handles MRO traversal and annotation merging for classes. I have been using typing_extensions rather than typing with the understanding that typing_extensions is providing a forward-compatibility layer. The forward-compatibility assumption is violated in this case.

@JelleZijlstra
Copy link
Member

I can't reproduce what you say on 3.10: both typing.get_type_hints and typing_extensions.get_type_hints fail.

$ cat py/bin/textbug.py 
from __future__ import annotations

import dataclasses as dcls
import inspect
import typing
import typing_extensions as typ


@dcls.dataclass
class Foo:

    y: int
    x: typ.ClassVar[ int ] = 1


print("anno", Foo.__init__.__annotations__ )
print("inspect", inspect.get_annotations( Foo.__init__, eval_str = True ) )
print('typing', typing.get_type_hints( Foo.__init__ ) )
print("text", typ.get_type_hints( Foo.__init__ ) )
$ uv run --python=3.10 --with typing-extensions py/bin/textbug.py 
anno {'y': 'int', 'x': 'typ.ClassVar[int]', 'return': None}
inspect {'y': <class 'int'>, 'x': typing.ClassVar[int], 'return': None}
Traceback (most recent call last):
  File "/Users/jelle/py/bin/textbug.py", line 18, in <module>
    print('typing', typing.get_type_hints( Foo.__init__ ) )
  File "/Users/jelle/.local/share/uv/python/cpython-3.10.16-macos-aarch64-none/lib/python3.10/typing.py", line 1871, in get_type_hints
    value = _eval_type(value, globalns, localns)
  File "/Users/jelle/.local/share/uv/python/cpython-3.10.16-macos-aarch64-none/lib/python3.10/typing.py", line 327, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "/Users/jelle/.local/share/uv/python/cpython-3.10.16-macos-aarch64-none/lib/python3.10/typing.py", line 693, in _evaluate
    type_ = _type_check(
  File "/Users/jelle/.local/share/uv/python/cpython-3.10.16-macos-aarch64-none/lib/python3.10/typing.py", line 167, in _type_check
    raise TypeError(f"{arg} is not valid as type argument")
TypeError: typing.ClassVar[int] is not valid as type argument

@emcd
Copy link
Author

emcd commented May 11, 2025

I can't reproduce what you say on 3.10: both typing.get_type_hints and typing_extensions.get_type_hints fail.

That is because the typing_extensions import apparently has a side effect on the way forward references work.
If you comment that import out and only using the typing import, then the example will succeed.

from __future__ import annotations

import dataclasses as dcls
import inspect
import typing as typ
# import typing_extensions as typ


@dcls.dataclass
class Foo:

    y: int
    x: typ.ClassVar[ int ] = 1


print( 'anno', Foo.__init__.__annotations__ )
print( 'inspect', inspect.get_annotations( Foo.__init__, eval_str = True ) )
print( 'typing', typ.get_type_hints( Foo.__init__ ) )
# print( 'text', typ.get_type_hints( Foo.__init__ ) )
$ hatch run python bugs/dcls-classvar-hints-jelle.py
anno {'y': 'int', 'return': None}
inspect {'y': <class 'int'>, 'return': None}
typing {'y': <class 'int'>, 'return': <class 'NoneType'>}

@Daraan
Copy link
Contributor

Daraan commented May 12, 2025

A important thing I found during debugging, it depends whether typing.ClassVar or typing_extensions.ClassVar is used. Despite them being the same object in the end, the code only works for the typing variant:

from __future__ import annotations

import dataclasses as dcls
import inspect
import typing
import typing_extensions as typ

@dcls.dataclass
class Bar:

    y: int
    x: typing.ClassVar[int] = 1

print("anno Bar", Bar.__init__.__annotations__)
print("inspect Bar", inspect.get_annotations(Bar.__init__, eval_str=True))
print("typing Bar", typing.get_type_hints(Bar.__init__))
print("text Bar", typ.get_type_hints(Bar.__init__))


@dcls.dataclass
class Foo:
    y: int
    x: typ.ClassVar[int] = 1

print("anno Foo", Foo.__init__.__annotations__)
print("inspect Foo", inspect.get_annotations(Foo.__init__, eval_str=True))
print("typing Foo", typing.get_type_hints(Foo.__init__))
print("text Foo", typ.get_type_hints(Foo.__init__))

Output:

anno Bar {'y': 'int', 'return': None}
inspect Bar {'y': <class 'int'>, 'return': None}
typing Bar {'y': <class 'int'>, 'return': <class 'NoneType'>}
text Bar {'y': <class 'int'>, 'return': <class 'NoneType'>}
anno Foo {'y': 'int', 'x': 'typ.ClassVar[int]', 'return': None}
inspect Foo {'y': <class 'int'>, 'x': typing.ClassVar[int], 'return': None}
Traceback (most recent call last):  # <-- error like above

@Daraan
Copy link
Contributor

Daraan commented May 12, 2025

Follow up. That looks like a bug dataclasses, or at least something that is not considered here. The derrived annotations of the __init__ differs, dropping the typing variant. As it is not in obj.__annotations__ it will not cause an error in get_type_hints

(Pdb) Foo.__init__.__annotations__
{'y': 'int', 'x': 'typ.ClassVar[int]', 'return': None}
(Pdb) Bar.__init__.__annotations__
{'y': 'int', 'return': None}

Some further insights can be found in this SO question and the dataclasses documentation

Class variables

One of the few places where @dataclass actually inspects the type of a field is to determine if a field is a class variable as defined in PEP 526. It does this by checking if the type of the field is typing.ClassVar. If a field is a ClassVar, it is excluded from consideration as a field and is ignored by the dataclass mechanisms. Such ClassVar pseudo-fields are not returned by the module-level fields() function.

I guess that dataclasses does infer that the annotation typ.ClassVartyping.ClassVar and hence handles it differently, i.e. not as a ClassVar but an instance variable.

@emcd
Copy link
Author

emcd commented May 12, 2025

A important thing I found during debugging, it depends whether typing.ClassVar or typing_extensions.ClassVar is used.

Ah, good find. I hadn't tried the combination of typing.ClassVar and typing_extensions.get_type_hints during my debugging.

While I agree that this points to a toxic interaction between dataclasses and class variable forms that are not strictly typing.ClassVar, it is worth noting that inspect.get_annotations (with eval_str) is able to resolve the annotation without error in either case, but {typing,typing_extensions}.get_type_hints raises an error. So, even if my original contention about a difference in the behavior of typing.get_type_hints and typing_extensions.get_type_hints is invalid, it is still somewhat concerning that get_type_hints cannot gracefully handle the same annotations that inspect.get_annotations can.

@emcd
Copy link
Author

emcd commented May 12, 2025

The derrived annotations of the __init__ differs, dropping the typing variant. As it is not in obj.__annotations__ it will not cause an error in get_type_hints

(Pdb) Foo.init.annotations
{'y': 'int', 'x': 'typ.ClassVar[int]', 'return': None}
(Pdb) Bar.init.annotations
{'y': 'int', 'return': None}

Correct. I noted this in my original message and suggested that dataclasses was at least partially at fault.

Slightly off-topic, but is it considered a good practice to silently drop annotations? What dataclasses is doing breaks introspection for some purposes. (I discovered this issue while working on a package to generate documentation via introspection.) If packages drop annotations, then we lose authoritative type information about attributes.

@JelleZijlstra
Copy link
Member

It doesn't drop annotations, since you're looking at annotations for __init__, not for the class itself. A ClassVar does not create an argument to __init__.

@emcd
Copy link
Author

emcd commented May 12, 2025

It doesn't drop annotations, since you're looking at annotations for __init__, not for the class itself. A ClassVar does not create an argument to __init__.

Ooops. You're correct. I forgot that we were looking at the synthetic __init__ method.

The remaining question is then whether the difference in typing.get_type_hints and inspect.get_annotations behavior is expected and acceptable? (I can file a separate issue in the CPython repo about this, if necessary.)

@JelleZijlstra
Copy link
Member

I think that's expected behavior, get_annotations doesn't validate annotations as "types" so it doesn't complain about ClassVar here.

@emcd
Copy link
Author

emcd commented May 12, 2025

I see. So, get_type_hints is trying to do something more than get_annotations with eval_str. Aside from mentioning that it unwraps types from Annotated and converts None to NoneType, the documentation does not really say what else it is expected to do with purported types.

Thank you for your time; I will file a separate issue in the CPython repo for the dataclasses bug (if someone hasn't already).

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

No branches or pull requests

3 participants