Skip to content
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

Fix Union[..., NoneType] injection by get_type_hints if a None default value is used. #482

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

Daraan
Copy link
Contributor

@Daraan Daraan commented Oct 9, 2024

Fixes #310

This PR reverts injection of Union[..., NoneType] by typing.get_type_hints in Python <3.11 if a function uses a None default value.

# 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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatively hints[name] = original_value which should be equivalent. I wonder which would be the safer alternative.

Copy link
Member

Choose a reason for hiding this comment

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

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].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the feedback. Yes, it should have been piped trough _eval_type as well. Can you take a look again?

src/test_typing_extensions.py Show resolved Hide resolved
src/typing_extensions.py Show resolved Hide resolved
Copy link
Member

@JelleZijlstra JelleZijlstra left a comment

Choose a reason for hiding this comment

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

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/test_typing_extensions.py Outdated Show resolved Hide resolved
src/typing_extensions.py Outdated Show resolved Hide resolved
src/typing_extensions.py Outdated Show resolved Hide resolved
# 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
Copy link
Member

Choose a reason for hiding this comment

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

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 Daraan marked this pull request as draft October 9, 2024 13:07
@Daraan Daraan marked this pull request as ready for review October 9, 2024 16:46
Copy link
Contributor Author

@Daraan Daraan left a comment

Choose a reason for hiding this comment

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

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.

# 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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the feedback. Yes, it should have been piped trough _eval_type as well. Can you take a look again?

src/typing_extensions.py Outdated Show resolved Hide resolved
src/test_typing_extensions.py Show resolved Hide resolved
src/typing_extensions.py Show resolved Hide resolved
@JelleZijlstra
Copy link
Member

I haven't thought too hard about examples that might break things, but I'm concerned about using == to check whether the two annotations are the same; I don't know if we can rely on equality of annotation objects working reliably for this.

@Daraan
Copy link
Contributor Author

Daraan commented Oct 10, 2024

I haven't thought too hard about examples that might break things, but I'm concerned about using == to check whether the two annotations are the same; I don't know if we can rely on equality of annotation objects working reliably for this.

Do you refer to assertEqual here, or also for the ones in typing_extensions.py?

  • I'll also added hash checks, which are fine. str and repr are fine, too.
  • assertIs fails in some(to-many) cases depending on the python version.
    In essence the problem can be seen here:
import typing
from typing import List
a = List["str"]

print(typing._eval_type(a, None, None) is typing._eval_type(a, None, None))  # False

typing._eval_type uses _GenericAlias.copy_with or in some cases creates a new GenericAlias which do not make use of the caches.

@Daraan Daraan marked this pull request as draft October 10, 2024 17:45
@Daraan Daraan marked this pull request as ready for review October 11, 2024 15:10
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))
Copy link
Member

Choose a reason for hiding this comment

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

Concatenating these seems a bit odd. In any case, type_hints is a dictionary, so the str() and repr() are the same.

Copy link
Contributor Author

@Daraan Daraan Oct 21, 2024

Choose a reason for hiding this comment

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

Thanks for pointing this out I was not 100% sure here and kept a lazy version. Changed to only repr.

src/test_typing_extensions.py Outdated Show resolved Hide resolved
src/typing_extensions.py Show resolved Hide resolved
):
continue
original_value = original_hints[name]
if original_value is None: # should not happen
Copy link
Member

Choose a reason for hiding this comment

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

Why shouldn't this happen? You can put None in annotations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines 1310 to 1315
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
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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 new object as _tp_cache is not queried. 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.

@@ -1282,7 +1282,7 @@ def _clean_optional(obj, hints, globalns=None, localns=None):
):
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
Copy link
Member

Choose a reason for hiding this comment

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

It shouldn't be NoneType already, since we get this directly from __annotations__.

Copy link
Contributor Author

@Daraan Daraan Oct 21, 2024

Choose a reason for hiding this comment

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

Excuse me I have argued wrongly. You are right that None would be from __annotations__. In such a case value should be NoneType and _could_be_inserted_optional(NoneType) would have already skipped this iteration (get_origin(NoneType) is not Union).

That's why original_value would still never be None here; updated the comment.

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

Successfully merging this pull request may close these issues.

Annotated/get_type_hints interaction in python <3.11
2 participants