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 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
376ae56
Fix get_type_hints with None default interaction
Daraan Oct 9, 2024
24c5602
moved section
Daraan Oct 9, 2024
7f000b1
removed empty line
Daraan Oct 9, 2024
8cc4464
Restructured tests and applied suggested changes
Daraan Oct 9, 2024
881ffee
+1 test and formatting
Daraan Oct 9, 2024
58082b9
Increase test coverage
Daraan Oct 10, 2024
5c94bd6
use key access and not values
Daraan Oct 10, 2024
b42e203
str test passes as well
Daraan Oct 10, 2024
ed552e4
removed invalid 3.8 code
Daraan Oct 10, 2024
313ddd8
Extended tests
Daraan Oct 10, 2024
3ba4ee7
refinement of tests, more tests with aliases
Daraan Oct 11, 2024
6ffcd23
Merge 'main' into get_type_hints
Daraan Oct 11, 2024
403e7ce
Merge branch 'main' into get_type_hints
Daraan Oct 15, 2024
52c93e9
Add comment for special case
Daraan Oct 21, 2024
a1777f3
Assure UnionType stays UnionType; check repr only
Daraan Oct 21, 2024
9e59796
Update src/test_typing_extensions.py
Daraan Oct 21, 2024
1761a43
Merge remote-tracking branch 'upstream/main' into get_type_hints
Daraan Oct 21, 2024
54b8eb0
Corrected and clarified case
Daraan Oct 21, 2024
5612f6f
Merge remote-tracking branch 'upstream/main' into get_type_hints
Daraan Oct 22, 2024
91075ff
Merge branch 'main' into get_type_hints
Daraan Oct 29, 2024
1eac721
Merge branch 'main' into get_type_hints
Daraan Nov 25, 2024
1f84c68
Merge branch 'main' into get_type_hints
Daraan Nov 27, 2024
214dfef
Merge branch 'main' into get_type_hints
Daraan Dec 12, 2024
00dedc2
Merge branch 'main' into get_type_hints
Daraan Jan 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
subscripted objects) had wrong parameters if they were directly
subscripted with an `Unpack` object.
Patch by [Daraan](https://github.com/Daraan).
- Fix backport of `get_type_hints` to reflect Python 3.11+ behavior which does not add
`Union[..., NoneType]` to annotations that have a `None` default value anymore.
This fixes wrapping of `Annotated` in an unwanted `Optional` in such cases.
Patch by [Daraan](https://github.com/Daraan).

# Release 4.12.2 (June 7, 2024)

Expand Down
89 changes: 89 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,95 @@ def test_final_forward_ref(self):
self.assertNotEqual(gth(Loop, globals())['attr'], Final[int])
self.assertNotEqual(gth(Loop, globals())['attr'], Final)

def test_annotation_and_optional_default(self):
Daraan marked this conversation as resolved.
Show resolved Hide resolved
annotation = Annotated[Union[int, None], "data"]
NoneAlias = None
StrAlias = str
T_default = TypeVar("T_default", default=None)
Ts = TypeVarTuple("Ts")

cases = {
# annotation: expected_type_hints
Annotated[None, "none"] : Annotated[None, "none"],
annotation : annotation,
Optional[int] : Optional[int],
Optional[List[str]] : Optional[List[str]],
Optional[annotation] : Optional[annotation],
Union[str, None, str] : Optional[str],
Unpack[Tuple[int, None]]: Unpack[Tuple[int, None]],
# Note: A starred *Ts will use typing.Unpack in 3.11+ see Issue #485
Unpack[Ts] : Unpack[Ts],
}
# contains a ForwardRef, TypeVar(~prefix) or no expression
do_not_stringify_cases = {
() : {}, # Special-cased below to create an unannotated parameter
int : int,
"int" : int,
None : type(None),
"NoneAlias" : type(None),
List["str"] : List[str],
Union[str, "str"] : str,
Union[str, None, "str"] : Optional[str],
Union[str, "NoneAlias", "StrAlias"]: Optional[str],
Union[str, "Union[None, StrAlias]"]: Optional[str],
Union["annotation", T_default] : Union[annotation, T_default],
Annotated["annotation", "nested"] : Annotated[Union[int, None], "data", "nested"],
}
if TYPING_3_10_0: # cannot construct UnionTypes before 3.10
do_not_stringify_cases["str | NoneAlias | StrAlias"] = str | None
cases[str | None] = Optional[str]
cases.update(do_not_stringify_cases)
for (annot, expected), none_default, as_str, wrap_optional in itertools.product(
cases.items(), (False, True), (False, True), (False, True)
):
# Special case:
skip_reason = None
annot_unchanged = annot
if sys.version_info[:2] == (3, 10) and annot == "str | NoneAlias | StrAlias" and none_default:
# In 3.10 converts Optional[str | None] to Optional[str] which has a different repr
skip_reason = "UnionType not preserved in 3.10"
if wrap_optional:
if annot_unchanged == ():
continue
annot = Optional[annot]
expected = {"x": Optional[expected]}
else:
expected = {"x": expected} if annot_unchanged != () else {}
if as_str:
if annot_unchanged in do_not_stringify_cases or annot_unchanged == ():
continue
annot = str(annot)
with self.subTest(
annotation=annot,
as_str=as_str,
wrap_optional=wrap_optional,
none_default=none_default,
expected_type_hints=expected,
):
# Create function to check
if annot_unchanged == ():
if none_default:
def func(x=None): pass
else:
def func(x): pass
elif none_default:
def func(x: annot = None): pass
else:
def func(x: annot): pass
type_hints = get_type_hints(func, globals(), locals(), include_extras=True)
# Equality
self.assertEqual(type_hints, expected)
# Hash
for k in type_hints.keys():
self.assertEqual(hash(type_hints[k]), hash(expected[k]))
# Test if UnionTypes are preserved
self.assertEqual(isinstance(type_hints[k], type(expected[k])), True)
Daraan marked this conversation as resolved.
Show resolved Hide resolved
# Repr
with self.subTest("Check str and repr"):
if skip_reason == "UnionType not preserved in 3.10":
self.skipTest(skip_reason)
self.assertEqual(repr(type_hints), repr(expected))


class GetUtilitiesTestCase(TestCase):
def test_get_origin(self):
Expand Down
79 changes: 79 additions & 0 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1236,10 +1236,89 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
)
else: # 3.8
hint = typing.get_type_hints(obj, globalns=globalns, localns=localns)
if sys.version_info < (3, 11):
_clean_optional(obj, hint, globalns, localns)
if sys.version_info < (3, 9):
# In 3.8 eval_type does not flatten Optional[ForwardRef] correctly
# This will recreate and and cache Unions.
hint = {
k: (t
if get_origin(t) != Union
else Union[t.__args__])
for k, t in hint.items()
}
if include_extras:
return hint
return {k: _strip_extras(t) for k, t in hint.items()}

_NoneType = type(None)

def _could_be_inserted_optional(t):
"""detects Union[..., None] pattern"""
JelleZijlstra marked this conversation as resolved.
Show resolved Hide resolved
# 3.8+ compatible checking before _UnionGenericAlias
if get_origin(t) is not Union:
return False
# Assume if last argument is not None they are user defined
if t.__args__[-1] is not _NoneType:
return False
return True

# < 3.11
def _clean_optional(obj, hints, globalns=None, localns=None):
# reverts injected Union[..., None] cases from typing.get_type_hints
# when a None default value is used.
# see https://github.com/python/typing_extensions/issues/310
if not hints or isinstance(obj, type):
return
defaults = typing._get_defaults(obj) # avoid accessing __annotations___
if not defaults:
return
original_hints = obj.__annotations__
for name, value in hints.items():
# Not a Union[..., None] or replacement conditions not fullfilled
if (not _could_be_inserted_optional(value)
or name not in defaults
or defaults[name] is not None
):
continue
original_value = original_hints[name]
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.

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

original_value = _NoneType
# Forward reference
if isinstance(original_value, str):
if globalns is None:
if isinstance(obj, _types.ModuleType):
globalns = obj.__dict__
else:
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
elif localns is None:
localns = globalns
if sys.version_info < (3, 9):
original_value = ForwardRef(original_value)
else:
original_value = ForwardRef(
original_value,
is_argument=not isinstance(obj, _types.ModuleType)
)
original_evaluated = typing._eval_type(original_value, globalns, localns)
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. Note that even if equal
# value might be cached by typing._tp_cache contrary to original_evaluated
if original_evaluated != value or (
# 3.10: ForwardRefs of UnionType might be turned into _UnionGenericAlias
hasattr(_types, "UnionType")
and isinstance(original_evaluated, _types.UnionType)
and not isinstance(value, _types.UnionType)
):
hints[name] = original_evaluated

# Python 3.9+ has PEP 593 (Annotated)
if hasattr(typing, 'Annotated'):
Expand Down
Loading