Fix Union[..., NoneType] injection by get_type_hints if a None default value is used.#482
Conversation
src/typing_extensions.py
Outdated
| # Values was not modified or original is already Optional | ||
| if original_value == value or _could_be_inserted_optional(original_value): | ||
| continue | ||
| # NoneType was added to value |
There was a problem hiding this comment.
Alternatively hints[name] = original_value which should be equivalent. I wonder which would be the safer alternative.
There was a problem hiding this comment.
Using original_value is incorrect as we may have modified the internals of the hint. For example, get_type_hints() turns List["int"] into List[int].
There was a problem hiding this comment.
Thank you for the feedback. Yes, it should have been piped trough _eval_type as well. Can you take a look again?
JelleZijlstra
left a comment
There was a problem hiding this comment.
This PR is incorrect for this example:
>>> def f(x: Union[str, None, "str"] = None): pass
...
>>> typing_extensions.get_type_hints(f)
{'x': <class 'str'>}
I am not sure this approach is viable.
src/typing_extensions.py
Outdated
| # Values was not modified or original is already Optional | ||
| if original_value == value or _could_be_inserted_optional(original_value): | ||
| continue | ||
| # NoneType was added to value |
There was a problem hiding this comment.
Using original_value is incorrect as we may have modified the internals of the hint. For example, get_type_hints() turns List["int"] into List[int].
Daraan
left a comment
There was a problem hiding this comment.
What are your thoughts on the "viability"? For that one example or in general?
I tried to improve the recreation of the typing.get_type_hints path that is taken before the injection.
src/typing_extensions.py
Outdated
| # Values was not modified or original is already Optional | ||
| if original_value == value or _could_be_inserted_optional(original_value): | ||
| continue | ||
| # NoneType was added to value |
There was a problem hiding this comment.
Thank you for the feedback. Yes, it should have been piped trough _eval_type as well. Can you take a look again?
|
I haven't thought too hard about examples that might break things, but I'm concerned about using |
Do you refer to
import typing
from typing import List
a = List["str"]
print(typing._eval_type(a, None, None) is typing._eval_type(a, None, None)) # False
|
fixed wrong variable and format
src/test_typing_extensions.py
Outdated
| with self.subTest("Check str and repr"): | ||
| if skip_reason == "UnionType not preserved in 3.10": | ||
| self.skipTest(skip_reason) | ||
| self.assertEqual(str(type_hints) + repr(type_hints), str(expected) + repr(expected)) |
There was a problem hiding this comment.
Concatenating these seems a bit odd. In any case, type_hints is a dictionary, so the str() and repr() are the same.
There was a problem hiding this comment.
Thanks for pointing this out I was not 100% sure here and kept a lazy version. Changed to only repr.
src/typing_extensions.py
Outdated
| ): | ||
| continue | ||
| original_value = original_hints[name] | ||
| if original_value is None: # should not happen |
There was a problem hiding this comment.
Why shouldn't this happen? You can put None in annotations.
There was a problem hiding this comment.
At this point a None value should already be converted to NoneType in original_hints. I think these two lines are redundant but to be safe I added the double check.
Added a comment to clarify this.
| if sys.version_info < (3, 9) and get_origin(original_evaluated) is Union: | ||
| # Union[str, None, "str"] is not reduced to Union[str, None] | ||
| original_evaluated = Union[original_evaluated.__args__] | ||
| # Compare if values differ | ||
| if original_evaluated != value: | ||
| hints[name] = original_evaluated |
There was a problem hiding this comment.
| if sys.version_info < (3, 9) and get_origin(original_evaluated) is Union: | |
| # Union[str, None, "str"] is not reduced to Union[str, None] | |
| original_evaluated = Union[original_evaluated.__args__] | |
| # Compare if values differ | |
| if original_evaluated != value: | |
| hints[name] = original_evaluated | |
| hints[name] = original_evaluated |
Would this also work?
There was a problem hiding this comment.
Yes it would. However there are minor differences involving identities and caching.
typing._eval_type uses _GenericAlias.copy_with or in some cases creates a new GenericAlias which do not make use of the caches.
If a ForwardRef is involved original_evaluated can be a complete new instance as _tp_cache is not queried for an existing equivalent one. Not changing hints["x"] will return an object that matches expected["x"] by identity in more cases.
That's a minor feature and I am fine with dropping it if you want; for now added a comment to clarify this.
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
+ updated comments
src/typing_extensions.py
Outdated
| continue | ||
| original_value = original_hints[name] | ||
| if original_value is None: # should not happen | ||
| if original_value is None: # should be NoneType already; check just in case |
There was a problem hiding this comment.
It shouldn't be NoneType already, since we get this directly from __annotations__.
There was a problem hiding this comment.
(Edited for more clarity)
Excuse me I have argued wrongly. You are right that None would be from __annotations__. In such a case value should be NoneType. However, in that case _could_be_inserted_optional(NoneType) will skip this iteration (as get_origin(NoneType) is not Union).
That's why code with a original_value that is None should never propagate until this line, but keeping these two lines to keep it safe; updated the comment.
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
|
I am not sure why its still flagged as changes requested from me. I think this should be again waiting for review :) |
Fixes #310
This PR reverts injection of
Union[..., NoneType]bytyping.get_type_hintsin Python <3.11 if a function uses aNonedefault value.