Repository navigation
Cross-module dataclass inheritance breaks get_type_hints #89687
Description
Activity
aidanbclark commented
on Oct 19, 2021 aidanbclarkmannequinMannequinAuthorMore actions[I believe this is fundamentally a dataclass version of https://bugs.python.org/issue41249]
When using
from __future__ import annotations, calling get_type_hints on the constructor of a dataclass B which inherits from a dataclass A defined in another module will error if dataclass A has type hints which are not imported in the module where dataclass B is defined.This is best shown by example, if you have foo.py:
from __future__ import annotations import collections import dataclasses @dataclasses.dataclass class A: x: collections.OrderedDictand then in bar.py:
from __future__ import annotations import foo import dataclasses import typing @dataclasses.dataclass class B(foo.A): pass typing.get_type_hints(B)the final line will raise "NameError: name 'collections' is not defined".
This code will not error if you do either of the following:
- add
import collectionsto bar.py. - remove the future annotations import from both files.
I am not confident enough on the internals of dataclass to suggest a fix, but potentially a similar approach to that which solved the TypedDict equivalent https://bugs.python.org/issue41249 would work?
- add
- added3.9 (EOL)end of lifeend of lifestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
on Oct 19, 2021 I can't reproduce this on Python 3.8.3, 3.9.6, 3.10.0 or 3.11.0a1+. Which versions of Python have you tried this on? (I'm able to reproduce the
typing.TypedDictbug on Python 3.8, since the fix was only backported to 3.9, but not this.)I think the example has a minor typo.
The crash is reproducible on 3.9 if the last line in bar.py is
typing.get_type_hints(B.__init__)
instead of
typing.get_type_hints(B)
Reacted by Tuukka MustonenThanks @sergei. With that alteration, I have reproduced this on Python 3.9, 3.10 and 3.11.
aidanbclark commented
on Oct 19, 2021 aidanbclarkmannequinMannequinAuthorMore actionsAh yes, I completely apologize about that typo, the __init__ is definitely needed (serves me right for trying to clean up a repo before posting it).
One other comment to make, perhaps obvious; manually passing a namespace to get_type_hints'
globalnswhich containscollectionsdoes indeed solve the problem (in case anyone else sees this and is blocked). Of course determining the right values to pass is in general difficult for a user (and shouldn't be necessary).I had some time to debug this. It happens because we treat classes and functions differently.
The difference between
get_type_hints(B)andget_type_hints(B.__init__)is that we use differentglobalcontexts there:- For
Bwe use__mro__entries and extractglobalsandlocalsfrom there:Lines 1792 to 1796 in 86dfb55
for base in reversed(obj.__mro__): if globalns is None: base_globals = getattr(sys.modules.get(base.__module__, None), '__dict__', {}) else: base_globals = globalns - For
B.__init__we use simplier logicIt does not know anything aboutLines 1822 to 1828 in 86dfb55
nsobj = obj # Find globalns for the unwrapped object. while hasattr(nsobj, '__wrapped__'): nsobj = nsobj.__wrapped__ globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns __mro__and super contexts
Funny thing, this problem goes away if we remove
@dataclassdecorator and convert our examples into regular classes:# a.py from __future__ import annotations import collections class A: x: collections.OrderedDict def __init__(self, x: collections.OrderedDict) -> None: ...and
# b.py from __future__ import annotations import a import typing class B(a.A): pass print(typing.get_type_hints(B)) # {'x': <class 'collections.OrderedDict'>} print(typing.get_type_hints(B.__init__)) # {'x': <class 'collections.OrderedDict'>, 'return': <class 'NoneType'>}I am going to try to solve this with something really simple (if no one else is working on it).
- For
@nikita, I had a go at writing some more rigorous tests regarding this issue. I found the same thing you did -- the issue seems:
- Isolated to dataclasses specifically (doesn't occur with TypedDicts, standard classes or NamedTuples)
- Isolated to the __init__ method of dataclasses
- Only occurs when you *subclass* a dataclass defined in another module.
My tests are in these two files on my cpython fork:
- https://github.com/AlexWaygood/cpython/blob/forward-annotations-bpo-45524/Lib/test/test_future_annotations.py
- https://github.com/AlexWaygood/cpython/blob/forward-annotations-bpo-45524/Lib/test/_typing_imports_helper.py
(I'm not proposing adding two new files to the cpython test suite -- just put the tests in new files so that I could isolate the new tests from the rest of the test suite and understand the problem better.)
If you try running my test script with the
from __future__ import annotationsline at the top commented out, however, the error is the same. (from __future__ import annotationsis obviously still there in the module that's being imported by the test script.)Is it worth also addressing the case where a @dataclass/typing.TypeDict class is defined within a function?
from __future__ import annotations import typing from dataclasses import dataclass def make_A(): import collections @dataclass class A: x: collections.defaultdict return A A = make_A() @dataclass class B(A): y: int # NameError: name 'collections' is not defined print(typing.get_type_hints(B.__init__))@sergei, I believe that's a much larger issue, and one that's quite difficult to solve. It's one of the principal reasons why
from __future__ import annotationsbehaviour wasn't made the default in Python 3.10, as was originally the plan. (This behaviour doesn't play nicely with libraries such as pydantic that analyse annotations at runtime.) I think it's probably beyond the scope of this BPO issue :)https://mail.python.org/archives/list/python-dev@python.org/thread/CLVXXPQ2T2LQ5MP2Y53VVQFCXYWQJHKZ/
11 remaining items
I've tried python3.14rc1 with
annotationlib.get_annotations. It is still broken in the same way as it is withget_type_hintsTraceback (most recent call last): File "/src/tmp/dtc/child.py", line 21, in <module> print(annotationlib.get_annotations(Child.__init__, eval_str=True)) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.14/annotationlib.py", line 998, in get_annotations key: value if not isinstance(value, str) else eval(value, globals, locals) ~~~~^^^^^^^^^^^^^^^^^^^^^^^^ File "<string>", line 1, in <module> NameError: name 'LocalDep' is not definedYou are not showing a full code sample so I can't tell, but the problem may be that you're using
from __future__ import annotations. The behavior under the future import is largely unchanged in Python 3.14. If you want good introspection, don't usefrom __future__ import annotations.The current problem is:
a.py:class A: pass
b.py:
import a from annotationlib import ForwardRef class A: pass x = ForwardRef("A", module='a') cls = x.evaluate(globals=globals(), locals=locals()) assert cls is a.A
This raises an assertion error
If you pass
globals(), it uses those globals in preference to theownerparameter. This could perhaps be made more explicit in https://docs.python.org/3.14/library/annotationlib.html#annotationlib.ForwardRef.evaluate .It should not, I believe, because there is no other way to make forward ref bounded to module
It works if you just call
x.evaluate()in your example without passing explicitglobals().I'm not sure why you'd expect passing a different module's globals would help evaluate the ForwardRef.
Because it's what's happening inside
get_type_hintsI don't think so; its
globalsarguments defaults to None, not the calling module'sglobals(). Indeed,typing.evaluate_forward_ref(x)succeeds for your example too.I realize this might cause problems though if you call
get_type_hints()and need to passglobals=globals()for some ForwardRefs in there but not others. Not sure when that would happen though; I'd love self-contained examples where the current behavior doesn't work well.a.py:
from dataclasses import dataclass class A: pass @dataclass class D: a: A
b.py:
import a from typing import ForwardRef, evaluate_forward_ref, get_type_hints from annotationlib import get_annotations, Format from dataclasses import dataclass class A: pass @dataclass class D2(a.D): pass print(get_type_hints(a.D.__init__)) print(a.D.__init__.__annotations__) print(get_annotations(a.D.__init__, format=Format.VALUE)) print("D2") print(get_type_hints(D2.__init__)) print(D2.__init__.__annotations__) print(get_annotations(D2.__init__, format=Format.VALUE))
Playing with order of
AandD, if the annotation is string or not, I got different behavior, but it should not be different.
To fix this we should have a type hint holding module, but there isn't anything exceptForwardRefWhat I see now:
- Attributes of ForwardRef are uncodumented https://docs.python.org/3.14/library/annotationlib.html#annotationlib.ForwardRef
- In cases ForwardRef has
ownerormoduleset ( ForwardRef('A', is_class=True, owner=<class 'a.D'>)) it is resolved byget_type_hintsignoring information about it's origin. - string annotations in class are not converted to
ForwardRefand__annotations__strores them as is TypeAliasTypedoesn't expect module argument and is immutable so cannot be used as a lazy proxy to another module- dataclasses copy annotations of all fields (including parent ones) to
__init__
So, the question is:
How can I create an annotation (in runtume) which is lazy and references to the same type even if used or resolved in another place?As far as I can tell, things work correctly when creating a
ForwardRefwith an appropriateowner=and calling.evaluate(). Things also work correctly if you don't use string annotations.What doesn't work well is
get_type_hints(), because it's prone to passing in the wrong globals when it evaluates ForwardRefs. Unfortunately, that function has a lot of legacy behavior and is difficult to change. In user code, I'd recommend performing a simplified version ofget_type_hints()directly, i.e., callingannotationlib.get_annotations(), converting any raw strings into ForwardRefs with the appropriateowner, and then callingForwardRef.evaluate()as appropriate.Dataclasses could do better at preserving the owner of field types in the case of inheritance. I think this patch would be an improvement:
diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 53b3b54cfb3..e3d25fb0840 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -789,7 +789,10 @@ def _get_field(cls, a_name, a_type, default_kw_only): # Only at this point do we know the name and the type. Set them. f.name = a_name - f.type = a_type + if isinstance(a_type, str): + f.type = annotationlib.ForwardRef(a_type, owner=cls) + else: + f.type = a_type # Assume it's a normal field until proven otherwise. We're next # going to decide if it's a ClassVar or InitVar, everything elseOk, setting a
ForwardRefwithownerright on field type sounds better, but anyway it does nothing ifglobals/localsare passed. Looks like the encapuslation here is broken.I still have no idea about the idea behind the module/owner. From my perspecitve any
ForwardRefshould be bounded to a scope, so it can still access the same value in that scope. IfForwardRefis created within a specific module, it is expected to be solved using objects in that module. It is not a matter of solver but an origin where it is created.Talking about this, I can say that I do not see much difference between
TypeAliasTypeandForwardRef, but you cannot createTypeAliasTypeinstance providing another module so it cannot be used here.If you have any links related the design of
ForwardRefattributes, please share
get_type_hintswith dataclasses__init__generation #29158Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.
Show more details
GitHub fields:
bugs.python.org fields:
Linked PRs