Skip to content
12 changes: 12 additions & 0 deletions Lib/test/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,18 @@ def test_hash(self):
self.assertEqual(hash(int | str), hash(str | int))
self.assertEqual(hash(int | str), hash(typing.Union[int, str]))

def test_union_of_unhashable(self):
class UnhashableMeta(type):
__hash__ = None

class A(metaclass=UnhashableMeta): ...
class B(metaclass=UnhashableMeta): ...

self.assertEqual((A | B).__args__, (A, B))
self.assertRaises(TypeError, hash, A | B)
self.assertRaises(TypeError, hash, int | B)
self.assertRaises(TypeError, hash, A | int)
Comment thread
sobolevn marked this conversation as resolved.
Outdated

def test_instancecheck_and_subclasscheck(self):
for x in (int | str, typing.Union[int, str]):
with self.subTest(x=x):
Expand Down
89 changes: 89 additions & 0 deletions Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,18 @@ def test_union_union(self):
v = Union[u, Employee]
self.assertEqual(v, Union[int, float, Employee])

def test_union_of_unhashable(self):
class UnhashableMeta(type):
__hash__ = None

class A(metaclass=UnhashableMeta): ...
class B(metaclass=UnhashableMeta): ...

self.assertEqual(Union[A, B].__args__, (A, B))
self.assertRaises(TypeError, hash, Union[A, B])
self.assertRaises(TypeError, hash, Union[int, B])
self.assertRaises(TypeError, hash, Union[A, int])
Comment thread
sobolevn marked this conversation as resolved.
Outdated

def test_repr(self):
self.assertEqual(repr(Union), 'typing.Union')
u = Union[Employee, int]
Expand Down Expand Up @@ -8267,6 +8279,78 @@ def test_flatten(self):
self.assertEqual(A.__metadata__, (4, 5))
self.assertEqual(A.__origin__, int)

def test_deduplicate_from_union(self):
# Regular:
self.assertEqual(get_args(Annotated[int, 1] | int),
(Annotated[int, 1], int))
self.assertEqual(get_args(Union[Annotated[int, 1], int]),
(Annotated[int, 1], int))
self.assertEqual(get_args(Annotated[int, 1] | Annotated[int, 2] | int),
(Annotated[int, 1], Annotated[int, 2], int))
self.assertEqual(get_args(Union[Annotated[int, 1], Annotated[int, 2], int]),
(Annotated[int, 1], Annotated[int, 2], int))
self.assertEqual(get_args(Annotated[int, 1] | Annotated[str, 1] | int),
(Annotated[int, 1], Annotated[str, 1], int))
self.assertEqual(get_args(Union[Annotated[int, 1], Annotated[str, 1], int]),
(Annotated[int, 1], Annotated[str, 1], int))

# Duplicates:
self.assertEqual(Annotated[int, 1] | Annotated[int, 1] | int,
Annotated[int, 1] | int)
self.assertEqual(Union[Annotated[int, 1], Annotated[int, 1], int],
Union[Annotated[int, 1], int])

# Unhashable metadata:
self.assertEqual(get_args(str | Annotated[int, {}] | Annotated[int, set()] | int),
Comment thread
sobolevn marked this conversation as resolved.
(str, int, Annotated[int, {}], Annotated[int, set()]))
self.assertEqual(get_args(Union[str, Annotated[int, {}], Annotated[int, set()], int]),
(str, int, Annotated[int, {}], Annotated[int, set()]))
self.assertEqual(get_args(str | Annotated[int, {}] | Annotated[str, {}] | int),
(str, int, Annotated[int, {}], Annotated[str, {}]))
self.assertEqual(get_args(Union[str, Annotated[int, {}], Annotated[str, {}], int]),
(str, int, Annotated[int, {}], Annotated[str, {}]))

self.assertEqual(get_args(Annotated[int, 1] | str | Annotated[str, {}] | int),
(Annotated[int, 1], str, int, Annotated[str, {}]))
self.assertEqual(get_args(Union[Annotated[int, 1], str, Annotated[str, {}], int]),
(Annotated[int, 1], str, int, Annotated[str, {}]))

import dataclasses
@dataclasses.dataclass
class ValueRange:
lo: int
hi: int
v = ValueRange(1, 2)
self.assertEqual(get_args(Annotated[int, v] | None),
(type(None), Annotated[int, v]))
self.assertEqual(get_args(Union[Annotated[int, v], None]),
(type(None), Annotated[int, v]))
self.assertEqual(get_args(Optional[Annotated[int, v]]),
(type(None), Annotated[int, v]))

# Unhashable metadata duplicated:
self.assertEqual(Annotated[int, {}] | Annotated[int, {}] | int,
Annotated[int, {}] | int)
self.assertEqual(Annotated[int, {}] | Annotated[int, {}] | int,
int | Annotated[int, {}])
self.assertEqual(Union[Annotated[int, {}], Annotated[int, {}], int],
Union[Annotated[int, {}], int])
self.assertEqual(Union[Annotated[int, {}], Annotated[int, {}], int],
Union[int, Annotated[int, {}]])

def test_order_in_union(self):
import operator, functools
Comment thread
sobolevn marked this conversation as resolved.
Outdated

expr1 = Annotated[int, 1] | str | Annotated[str, {}] | int
for args in itertools.permutations(get_args(expr1)):
with self.subTest(args=args):
self.assertEqual(expr1, functools.reduce(operator.or_, args))

expr2 = Union[Annotated[int, 1], str, Annotated[str, {}], int]
for args in itertools.permutations(get_args(expr2)):
with self.subTest(args=args):
self.assertEqual(expr2, Union[args])

def test_specialize(self):
L = Annotated[List[T], "my decoration"]
LI = Annotated[List[int], "my decoration"]
Expand All @@ -8287,6 +8371,11 @@ def test_hash_eq(self):
{Annotated[int, 4, 5], Annotated[int, 4, 5], Annotated[T, 4, 5]},
{Annotated[int, 4, 5], Annotated[T, 4, 5]}
)
# Unhashable `metadata` raises `TypeError`:
self.assertRaises(TypeError, hash, Annotated[int, []])
class A:
__hash__ = None
self.assertRaises(TypeError, hash, Annotated[int, A()])
Comment thread
sobolevn marked this conversation as resolved.
Outdated

def test_instantiate(self):
class C:
Expand Down
84 changes: 70 additions & 14 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,19 +308,67 @@ def _unpack_args(args):
newargs.append(arg)
return newargs

def _deduplicate(params):
def _deduplicate(params, *, unhashable_fallback=True):
Comment thread
sobolevn marked this conversation as resolved.
Outdated
# Weed out strict duplicates, preserving the first of each occurrence.
all_params = set(params)
if len(all_params) < len(params):
new_params = []
for t in params:
if t in all_params:
new_params.append(t)
all_params.remove(t)
params = new_params
assert not all_params, all_params
return params

try:
all_params = set(params)
except TypeError:
if not unhashable_fallback:
raise

# Happens for cases like `Annotated[dict, {'x': IntValidator()}]`
hasable, unhashable = _deduplicate_unhashable(params)
return hasable + unhashable

if len(all_params) == len(params): # fast case
return params
new_params = []
for t in params:
Comment thread
sobolevn marked this conversation as resolved.
Outdated
if t in all_params:
new_params.append(t)
all_params.remove(t)
assert not all_params, all_params
return new_params

def _deduplicate_unhashable(params):
regular_params = []
unhashable_params = []
for param in params:
try:
hash(param)
except TypeError:
unhashable_params.append(param)
else:
regular_params.append(param)

unique_params = set(regular_params)
new_hashable = []
for t in regular_params:
if t in unique_params:
new_hashable.append(t)
unique_params.remove(t)
assert not unique_params, unique_params

new_unhashable = []
for t in unhashable_params:
# This is slow, but this is the only safe way to compare
# two `Annotated[]` instances, since `metadata` might be unhashable.
Comment thread
sobolevn marked this conversation as resolved.
Outdated
if t not in new_unhashable:
new_unhashable.append(t)
return new_hashable, new_unhashable

def _compare_args_orderless(first_args, second_args):
first_hashable, first_unhashable = _deduplicate_unhashable(first_args)
second_hashable, second_unhashable = _deduplicate_unhashable(second_args)
if set(first_hashable) != set(second_hashable):
return False
t = list(second_unhashable)
try:
for elem in first_unhashable:
t.remove(elem)
except ValueError:
return False
return not t

def _remove_dups_flatten(parameters):
"""Internal helper for Union creation and substitution.
Expand Down Expand Up @@ -751,7 +799,12 @@ def open_helper(file: str, mode: MODE) -> str:
parameters = _flatten_literal_params(parameters)

try:
parameters = tuple(p for p, _ in _deduplicate(list(_value_and_type_iter(parameters))))
parameters = tuple(
p for p, _ in _deduplicate(
list(_value_and_type_iter(parameters)),
unhashable_fallback=False,
)
)
except TypeError: # unhashable parameters
pass

Expand Down Expand Up @@ -1533,7 +1586,10 @@ def copy_with(self, params):
def __eq__(self, other):
if not isinstance(other, (_UnionGenericAlias, types.UnionType)):
return NotImplemented
return set(self.__args__) == set(other.__args__)
try: # fast path
return set(self.__args__) == set(other.__args__)
except TypeError: # not hashable, slow path
return _compare_args_orderless(self.__args__, other.__args__)

def __hash__(self):
return hash(frozenset(self.__args__))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Allow creating :ref:`union of types<types-union>` for
:class:`typing.Annotated` with unhashable / mutable metadata.
Comment thread
sobolevn marked this conversation as resolved.
Outdated